diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c9abeb --- /dev/null +++ b/.gitignore @@ -0,0 +1,153 @@ +# Python +# ====== + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +# node / npm / yarn +# ================= + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Editor +*.swp +*.swo + +# Firmware +# ======= + +# ESP-IDF +sdkconfig +sdkconfig.old + +# Custom +# ====== + +data/ +secrets.py +secrets.h +*.bin +output.* +out.* +*.csv +*.txt +*.json +.aider* diff --git a/js/Animation.js b/js/Animation.js new file mode 100644 index 0000000..9a88575 --- /dev/null +++ b/js/Animation.js @@ -0,0 +1,39 @@ +/* +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.) +*/ + + + + +export class Animation +{ + // assumes you're starting at 0 opacity, to get to 1 + static FadeOpacityUp(dom) + { + if (dom) + { + let Step; + + Step = () => { + dom.style.opacity = parseFloat(dom.style.opacity) + 0.6; + + if (dom.style.opacity >= 1) + { + dom.style.opacity = 1; + } + else + { + window.requestAnimationFrame(() => { Step() }); + } + }; + + window.requestAnimationFrame(() => { Step() }); + } + } +} + + diff --git a/js/Application.js b/js/Application.js new file mode 100644 index 0000000..109b7ac --- /dev/null +++ b/js/Application.js @@ -0,0 +1,60 @@ +/* +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 './js/Base.js'; +import { Timeline } from '/js/Timeline.js'; + +import { WsprSearchUi } from './js/WsprSearchUi.js'; + + +export class Application +extends Base +{ + constructor(cfg) + { + super(); + + // whoops, forgot about need to debug init code also, so turn this on + this.SetGlobalDebug(true); + + // cache config + this.cfg = cfg; + + // get handles for dom elements + // ... + + // UI + this.wsprSearchUi = new WsprSearchUi({ + searchInput: cfg.searchInputContainer, + helpLink: cfg.helpLink, + map: cfg.mapContainer, + charts: cfg.chartsContainer, + flightStats: cfg.flightStatsContainer, + dataTable: cfg.dataTableContainer, + searchStats: cfg.searchStatsContainer, + filterStats: cfg.filterStatsContainer, + }); + + // debug + this.SetDebug(true); + } + + SetDebug(tf) + { + super.SetDebug(tf); + + this.wsprSearchUi.SetDebug(this.debug); + } + + Run() + { + super.Run(); + } +} diff --git a/js/AsyncResourceLoader.js b/js/AsyncResourceLoader.js new file mode 100644 index 0000000..5c13228 --- /dev/null +++ b/js/AsyncResourceLoader.js @@ -0,0 +1,75 @@ +/* +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.) +*/ + + +/////////////////////////////////////////////////////////////////////////////// +// Cache subsequent loads for the same resource, which all takes their own +// load time, even when the url is the same. +/////////////////////////////////////////////////////////////////////////////// + +export class AsyncResourceLoader +{ + static url__scriptPromise = new Map(); + static AsyncLoadScript(url) + { + if (this.url__scriptPromise.has(url) == false) + { + let p = new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = url; + script.async = true; + + script.onload = () => { + resolve(); + }; + + script.onerror = (message) => { + reject(new Error(message)); + } + + document.body.appendChild(script); + }); + + this.url__scriptPromise.set(url, p); + } + + let p = this.url__scriptPromise.get(url); + + return p; + } + + static url__stylesheetPromise = new Map(); + static AsyncLoadStylesheet(url) + { + if (this.url__stylesheetPromise.has(url) == false) + { + let p = new Promise((resolve, reject) => { + const link = document.createElement('link'); + link.rel = "stylesheet"; + link.href = url; + link.async = true; + + link.onload = () => { + resolve(); + }; + + link.onerror = (message) => { + reject(new Error(message)); + }; + + document.body.appendChild(link); + }); + + this.url__stylesheetPromise.set(url, p); + } + + let p = this.url__stylesheetPromise.get(url); + + return p; + } +} diff --git a/js/CSSDynamic.js b/js/CSSDynamic.js new file mode 100644 index 0000000..82d206c --- /dev/null +++ b/js/CSSDynamic.js @@ -0,0 +1,103 @@ +/* +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.) +*/ + + + +export class CSSDynamic +{ + // Find the CSSStyleRule for the class + GetCssRule(className) + { + let retVal = null; + + for (const sheet of document.styleSheets) + { + if (sheet.href == null) + { + try + { + for (const rule of sheet.cssRules) + { + if (rule.selectorText === className) + { + retVal = rule; + break; + } + } + } + catch (e) + { + // Catch and ignore CORS-related issues + console.warn(`Cannot access stylesheet: ${sheet.href}: ${e}`); + } + } + } + + return retVal; + } + + MakeCssRule(ruleName) + { + let sheet = null; + for (let ss of document.styleSheets) + { + if (ss.href == null) + { + sheet = ss; + break; + } + } + + const ruleIndex = sheet.cssRules.length; + + // Add a new rule if it doesn't exist + sheet.insertRule(`${ruleName} {}`, ruleIndex); + } + + // don't include the '.' before class name, handled automatically + GetOrMakeCssClass(ccName) + { + let rule = this.GetCssRule(`.${ccName}`); + + if (rule == null) + { + this.MakeCssRule(`.${ccName}`); + } + + rule = this.GetCssRule(`.${ccName}`); + + return rule; + } + + // eg ("MyClass", { color: 'red', border: '1 px solid black', }) + SetCssClassProperties(ccName, styles) + { + let rule = this.GetOrMakeCssClass(ccName); + + Object.entries(styles).forEach(([key, value]) => { + rule.style[key] = value; + }); + } + + // Create the CSS rule for the (eg) :after pseudo-element + // if you want .ClassName::after, pass in "ClassName", "after" + SetCssClassDynamicProperties(className, pseudoElement, content, styles) { + const afterRule = `.${className}::${pseudoElement} { content: '${content}'; ${styles} }`; + + let styleSheet = document.querySelector('style[data-dynamic]'); + if (!styleSheet) + { + styleSheet = document.createElement('style'); + styleSheet.setAttribute('data-dynamic', ''); + document.head.appendChild(styleSheet); + } + + styleSheet.sheet.insertRule(afterRule, styleSheet.sheet.cssRules.length); + } +} + diff --git a/js/CandidateFilterBase.js b/js/CandidateFilterBase.js new file mode 100644 index 0000000..5574c1b --- /dev/null +++ b/js/CandidateFilterBase.js @@ -0,0 +1,96 @@ +/* +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 { Base } from './Base.js'; + + +/////////////////////////////////////////////////////////////////////////////// +// CandidateFilterBase +// +// Designed to be inherited from by a series of different Filter types +// which should conform to the same behavior. +// +// Class supplies: +// - public interface for users +// - boilerplate to for inherited classes to use +// - convenience functions for inherited classes to use +/////////////////////////////////////////////////////////////////////////////// + +export class CandidateFilterBase +extends Base +{ + constructor(type, t) + { + super(t); + + // inherited class identifies themselves + this.type = type; + } + +// public interface + + // main entry point for using the filter + Filter(forEachAble) + { + // fire event + this.OnFilterStart(); + + // foreach + forEachAble.ForEach((msgListList) => { + this.FilterWindowAlgorithm(msgListList) + }); + + // fire event + this.OnFilterEnd(); + } + + +// "virtual" functions + + OnFilterStart() + { + this.t.Event(`CandidateFilterBase::OnFilterStart`); + + // do nothing, placeholder in case inherited class does not implement + } + + FilterWindowAlgorithm(msgListList) + { + this.t.Event(`CandidateFilterBase::FilterWindowAlgorithm`); + + // do nothing, placeholder in case inherited class does not implement + } + + OnFilterEnd() + { + this.t.Event(`CandidateFilterBase::OnFilterEnd`); + + // do nothing, placeholder in case inherited class does not implement + } + + +// convenience functions + + RejectAllInListExcept(msgList, msgExcept, reason) + { + for (let msg of msgList) + { + if (msg != msgExcept) + { + msg.Reject(this.type, reason); + } + } + }; + + RejectAllInList(msgList, reason) + { + this.RejectAllInListExcept(msgList, null, reason); + } +} + + diff --git a/js/CandidateFilterByBadTelemetry.js b/js/CandidateFilterByBadTelemetry.js new file mode 100644 index 0000000..391b6e8 --- /dev/null +++ b/js/CandidateFilterByBadTelemetry.js @@ -0,0 +1,87 @@ +/* +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 { CandidateFilterBase } from './CandidateFilterBase.js'; +import { NonRejectedOnlyFilter } from './WsprMessageCandidate.js'; + + +/////////////////////////////////////////////////////////////////////////// +// Candidate Filter - Bad Telemetry +// +// Reject any msg which is detected as invalid +/////////////////////////////////////////////////////////////////////////// + +export class CandidateFilterByBadTelemetry +extends CandidateFilterBase +{ + constructor(t) + { + super("ByBadTelemetry", t); + } + + OnFilterStart() + { + this.t.Event(`CandidateFilterByBadTelemetry Start`); + } + + OnFilterEnd() + { + this.t.Event(`CandidateFilterByBadTelemetry End`); + } + + FilterWindowAlgorithm(msgListList) + { + // eliminate any extended telemetry marked as the wrong slot + for (let slot = 0; slot < 5; ++slot) + { + for (let msg of NonRejectedOnlyFilter(msgListList[slot])) + { + if (msg.IsTelemetryExtended()) + { + let codec = msg.GetCodec(); + + + // actually check if decode was bad + + + + + + let hdrTypeSupportedList = [ + 0, // user-defined + 1, // heartbeat + 2, // ExpandedBasicTelemetry + 3, // highResLocation + 15, // vendor-defined + ]; + + + + + + let hdrRESERVED = codec.GetHdrRESERVEDEnum(); + let hdrSlot = codec.GetHdrSlotEnum(); + let hdrType = codec.GetHdrTypeEnum(); + + if (hdrRESERVED != 0) + { + msg.Reject(this.type, `Bad Telemetry - HdrRESERVED is non-zero (${hdrRESERVED})`); + } + else if (hdrSlot != slot) + { + msg.Reject(this.type, `Bad Telemetry - HdrSlot (${hdrSlot}) set incorrectly, found in slot ${slot}`); + } + else if (hdrTypeSupportedList.indexOf(hdrType) == -1) + { + msg.Reject(this.type, `Bad Telemetry - HdrType (${hdrType}) set to unsupported value`); + } + } + } + } + } +} diff --git a/js/CandidateFilterByFingerprinting.js b/js/CandidateFilterByFingerprinting.js new file mode 100644 index 0000000..86827f5 --- /dev/null +++ b/js/CandidateFilterByFingerprinting.js @@ -0,0 +1,553 @@ +/* +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 { CandidateFilterBase } from './CandidateFilterBase.js'; +import { NonRejectedOnlyFilter } from './WsprMessageCandidate.js'; + + +/////////////////////////////////////////////////////////////////////////// +// Candidate Filter - Fingerprinting +// +// Identify messages that appear to be yours by matching frequencies +// to data you believe in. Reject everything else. +/////////////////////////////////////////////////////////////////////////// + +export class CandidateFilterByFingerprinting +extends CandidateFilterBase +{ + constructor(t) + { + super("ByFingerprinting", t); + } + + OnFilterStart() + { + this.t.Event(`CandidateFilterByFingerprinting Start`); + this.windowDataSet = new Set(); + } + + OnFilterEnd() + { + for (const windowData of this.windowDataSet) + { + if (windowData?.fingerprintingData) + { + windowData.fingerprintingData.winFreqDrift = this.EstimateWindowFreqDrift(windowData); + } + } + + this.t.Event(`CandidateFilterByFingerprinting End`); + } + + FilterWindowAlgorithm(msgListList) + { + this.FingerprintAlgorithm_ByReference(msgListList); + } + + +// private + + + /////////////////////////////////////////////////////////////////////////// + // ByReference Algorithm + // + // If you can find a reference message you believe to be yours, match up + // messages in the other slots by frequency to that reference frequency, + // then reject all others. + /////////////////////////////////////////////////////////////////////////// + + FingerprintAlgorithm_ByReference(msgListList) + { + let windowData = this.GetWindowDataFromMsgListList(msgListList); + if (windowData) + { + this.windowDataSet.add(windowData); + + if (windowData.fingerprintingData) + { + windowData.fingerprintingData.winFreqDrift = null; + } + } + + let reference = this.FindNearestReference(msgListList); + if (!reference.ok) + { + for (let msgList of msgListList) + { + let msgListCandidate = NonRejectedOnlyFilter(msgList); + this.RejectAllInList(msgListCandidate, reference.reason); + } + + if (windowData?.fingerprintingData) + { + windowData.fingerprintingData.referenceAudit = this.CreateReferenceAuditData(null, null, reference.source, reference.reason); + } + + return; + } + + let referenceMsg = reference.msg; + let referenceSlot = reference.slot; + windowData = referenceMsg.windowShortcut; + let referenceAudit = this.CreateReferenceAuditData(referenceMsg, referenceSlot, reference.source, reference.reason); + if (windowData?.fingerprintingData) + { + windowData.fingerprintingData.referenceAudit = referenceAudit; + } + + for (let slot = 0; slot < 5; ++slot) + { + if (slot == referenceSlot) + { + referenceAudit.slotAuditList[slot] = this.CreateSlotAuditData(slot, [referenceMsg], referenceMsg, referenceAudit); + referenceAudit.slotAuditList[slot].outcome = "reference"; + referenceAudit.slotAuditList[slot].msgMatched = referenceMsg; + referenceAudit.slotAuditList[slot].msgMatchList = [referenceMsg]; + continue; + } + + let msgCandidateList = NonRejectedOnlyFilter(msgListList[slot]); + + let msgMatchList = []; + let freqHzDiffMatch = 0; + const FREQ_HZ_PLUS_MINUS_THRESHOLD = 5; // it's +/- this number, so 10Hz + let slotAudit = this.CreateSlotAuditData(slot, msgCandidateList, referenceMsg, referenceAudit); + referenceAudit.slotAuditList[slot] = slotAudit; + if (msgCandidateList.length) + { + slotAudit.msgAuditList = this.GetMsgFingerprintAuditList(referenceMsg, msgCandidateList, referenceAudit.referenceRxCall__rxRecordListMap); + + let matchResult = this.GetWithinThresholdMatchResult(slotAudit.msgAuditList, FREQ_HZ_PLUS_MINUS_THRESHOLD); + msgMatchList = matchResult.msgMatchList; + freqHzDiffMatch = matchResult.freqHzDiffMatch; + slotAudit.matchThresholdHz = matchResult.matchThresholdHz; + } + + if (msgMatchList.length == 0) + { + this.RejectAllInList(msgCandidateList, `Fingerprint match fail, exceeded ${FREQ_HZ_PLUS_MINUS_THRESHOLD} threshold.`); + slotAudit.outcome = "no_match"; + } + else if (msgMatchList.length == 1) + { + this.RejectAllInListExcept(msgCandidateList, msgMatchList[0], `Fingerprint matched other message`); + slotAudit.outcome = "single_match"; + slotAudit.msgMatched = msgMatchList[0]; + } + else + { + slotAudit.outcome = "multi_match"; + } + + slotAudit.msgMatchList = msgMatchList; + slotAudit.thresholdHzTriedMax = msgCandidateList.length ? freqHzDiffMatch : null; + } + } + + FindNearestReference(msgListList) + { + let slot0List = NonRejectedOnlyFilter(msgListList[0]); + if (slot0List.length == 1) + { + return { + ok: true, + msg: slot0List[0], + slot: 0, + source: "slot0", + reason: "Using unique slot 0 message as fingerprint reference.", + }; + } + + for (let slot = 0; slot < 5; ++slot) + { + let msgConfirmedList = NonRejectedOnlyFilter(msgListList[slot]).filter(msg => msg.IsConfirmed()); + if (msgConfirmedList.length == 1) + { + return { + ok: true, + msg: msgConfirmedList[0], + slot: slot, + source: "borrowed_confirmed_within_window", + reason: `Borrowed fingerprint reference from earliest confirmed message in slot ${slot}.`, + }; + } + } + + if (slot0List.length == 0) + { + return { + ok: false, + msg: null, + slot: null, + source: "none", + reason: `No anchor frequency message in slot 0, and no confirmed message available to borrow within the window.`, + }; + } + + return { + ok: false, + msg: null, + slot: null, + source: "none", + reason: `Too many candidates (${slot0List.length}) in slot 0, and no unique confirmed message available to borrow within the window.`, + }; + } + + GetWindowDataFromMsgListList(msgListList) + { + for (const msgList of msgListList) + { + for (const msg of msgList) + { + if (msg?.windowShortcut) + { + return msg.windowShortcut; + } + } + } + + return null; + } + + CreateReferenceAuditData(referenceMsg, referenceSlot, referenceSource, referenceReason) + { + return { + referenceMsg: referenceMsg, + referenceSlot: referenceSlot, + referenceSource: referenceSource, + referenceReason: referenceReason, + referenceRxCall__rxRecordListMap: referenceMsg ? this.MakeRxCallToRxRecordListMap(referenceMsg) : new Map(), + slotAuditList: [null, null, null, null, null], + }; + } + + CreateSlotAuditData(slot, msgCandidateList, referenceMsg, referenceAudit) + { + return { + slot: slot, + referenceMsg: referenceMsg, + referenceSlot: referenceAudit.referenceSlot, + referenceRxCall__rxRecordListMap: referenceAudit.referenceRxCall__rxRecordListMap, + msgCandidateList: msgCandidateList, + msgAuditList: [], + msgMatchList: [], + msgMatched: null, + matchThresholdHz: null, + thresholdHzTriedMax: null, + outcome: msgCandidateList.length ? "pending" : "no_candidates", + }; + } + + GetMsgFingerprintAuditList(msgA, msgBList, rxCall__rxRecordListAMap) + { + let msgAuditList = []; + + for (let msgB of msgBList) + { + let diffData = this.GetMinFreqDiffData(msgA, msgB, rxCall__rxRecordListAMap); + msgAuditList.push({ + msg: msgB, + minFreqDiff: diffData.minDiff, + rxCallMatchList: diffData.rxCallMatchList, + candidateRxCall__rxRecordListMap: diffData.rxCall__rxRecordListB, + }); + } + + return msgAuditList; + } + + GetWithinThresholdMatchResult(msgAuditList, thresholdMax) + { + let msgMatchList = []; + let freqHzDiffMatch = thresholdMax; + let matchThresholdHz = null; + + for (freqHzDiffMatch = 0; freqHzDiffMatch <= thresholdMax; ++freqHzDiffMatch) + { + msgMatchList = msgAuditList + .filter(msgAudit => msgAudit.minFreqDiff != null && msgAudit.minFreqDiff <= freqHzDiffMatch) + .map(msgAudit => msgAudit.msg); + + if (msgMatchList.length != 0) + { + matchThresholdHz = freqHzDiffMatch; + break; + } + } + + return { + msgMatchList, + freqHzDiffMatch, + matchThresholdHz, + }; + } + + // Return the set of msgBList elements which fall within the threshold difference + // of frequency when compared to msgA. + GetWithinThresholdList(msgA, msgBList, threshold) + { + let msgListWithinThreshold = []; + + // calculate minimum frequency diff between msgA and this + // message of this slot + let msg__minFreqDiff = new Map(); + for (let msgB of msgBList) + { + msg__minFreqDiff.set(msgB, this.GetMinFreqDiff(msgA, msgB)); + } + + // find out which messages fall within tolerance + for (let [msgB, freqDiff] of msg__minFreqDiff) + { + if (freqDiff != null && freqDiff <= threshold) + { + msgListWithinThreshold.push(msgB); + } + } + + return msgListWithinThreshold; + } + + MakeRxCallToRxRecordListMap(msg, limitBySet) + { + limitBySet = limitBySet ?? null; + + let rxCall__recordMap = new Map(); + + for (let rxRecord of msg.rxRecordList) + { + let rxCall = rxRecord.rxCallsign; + + if (limitBySet == null || limitBySet.has(rxCall)) + { + if (rxCall__recordMap.has(rxCall) == false) + { + rxCall__recordMap.set(rxCall, []); + } + + rxCall__recordMap.get(rxCall).push(rxRecord); + } + } + + return rxCall__recordMap; + } + + // Find min diff of entries in B compared to looked up in A. + // Only compare equal rxCallsigns. + // So, we're looking at: + // - for any common rxCallsign + // - across all frequencies reported by that rxCallsign + // - what is the minimum difference in frequency seen? + GetMinFreqDiff(msgA, msgB) + { + return this.GetMinFreqDiffData(msgA, msgB).minDiff; + } + + GetMinFreqDiffData(msgA, msgB, rxCall__rxRecordListAMap) + { + let rxCall__rxRecordListA = rxCall__rxRecordListAMap ?? this.MakeRxCallToRxRecordListMap(msgA); + let rxCall__rxRecordListB = this.MakeRxCallToRxRecordListMap(msgB, rxCall__rxRecordListA); + + let minDiff = null; + let rxCallMatchList = []; + for (let [rxCall, rxRecordListB] of rxCall__rxRecordListB) + { + let rxRecordListA = rxCall__rxRecordListA.get(rxCall); + + // unavoidable(?) M*N operation here, hopefully M and N are small + let diff = this.GetMinFreqDiffRxRecordList(rxRecordListA, rxRecordListB); + + rxCallMatchList.push({ + rxCall, + rxRecordListA, + rxRecordListB, + minFreqDiff: diff, + }); + + if (minDiff == null || diff < minDiff) + { + minDiff = diff; + } + } + + return { + minDiff, + rxCall__rxRecordListA, + rxCall__rxRecordListB, + rxCallMatchList, + }; + } + + // Returns the smallest absolute difference between frequencies found in the two + // supplied record lists. This is an M*N operation. + // + // This function has no knowledge or assumptions about the contents of the + // two lists (ie whether the callsigns are the same). + // + // This is simply a function broken out to keep calling code simpler. + GetMinFreqDiffRxRecordList(rxRecordListA, rxRecordListB) + { + let minDiff = null; + + for (let rxRecordA of rxRecordListA) + { + for (let rxRecordB of rxRecordListB) + { + let diff = Math.abs(rxRecordA.frequency - rxRecordB.frequency); + + if (minDiff == null || diff < minDiff) + { + minDiff = diff; + } + } + } + + return minDiff; + } + + EstimateWindowFreqDrift(windowData) + { + let referenceAudit = windowData?.fingerprintingData?.referenceAudit; + if (!referenceAudit?.referenceMsg) + { + return null; + } + + let slotEstimateList = []; + for (let slot = (referenceAudit.referenceSlot + 1); slot < 5; ++slot) + { + let slotAudit = referenceAudit.slotAuditList?.[slot]; + let slotDelta = this.EstimateWindowFreqDriftForSlot(slotAudit); + if (slotDelta == null) + { + continue; + } + + slotEstimateList.push({ + deltaHz: slotDelta, + weight: slot - referenceAudit.referenceSlot, + }); + } + + if (slotEstimateList.length == 0) + { + return null; + } + + let weightedSum = 0; + let weightSum = 0; + for (const estimate of slotEstimateList) + { + weightedSum += estimate.deltaHz * estimate.weight; + weightSum += estimate.weight; + } + + if (weightSum == 0) + { + return null; + } + + return Math.round(weightedSum / weightSum); + } + + EstimateWindowFreqDriftForSlot(slotAudit) + { + if (!slotAudit?.msgAuditList?.length || !slotAudit?.msgMatchList?.length) + { + return null; + } + + let msgMatchSet = new Set(slotAudit.msgMatchList); + let rxCall__bestDelta = new Map(); + + for (const msgAudit of slotAudit.msgAuditList) + { + if (!msgMatchSet.has(msgAudit.msg)) + { + continue; + } + + for (const rxCallMatch of msgAudit.rxCallMatchList || []) + { + let bestSignedDelta = this.GetBestSignedFreqDelta(rxCallMatch.rxRecordListA, rxCallMatch.rxRecordListB); + if (bestSignedDelta == null) + { + continue; + } + + let rxCall = rxCallMatch.rxCall; + if (!rxCall__bestDelta.has(rxCall)) + { + rxCall__bestDelta.set(rxCall, bestSignedDelta); + continue; + } + + let cur = rxCall__bestDelta.get(rxCall); + if (Math.abs(bestSignedDelta) < Math.abs(cur)) + { + rxCall__bestDelta.set(rxCall, bestSignedDelta); + } + } + } + + let deltaList = Array.from(rxCall__bestDelta.values()); + if (deltaList.length == 0) + { + return null; + } + + return this.GetMedian(deltaList); + } + + GetBestSignedFreqDelta(rxRecordListA, rxRecordListB) + { + if (!Array.isArray(rxRecordListA) || !Array.isArray(rxRecordListB)) + { + return null; + } + + let bestSignedDelta = null; + for (const rxRecordA of rxRecordListA) + { + for (const rxRecordB of rxRecordListB) + { + let freqA = Number(rxRecordA?.frequency); + let freqB = Number(rxRecordB?.frequency); + if (!Number.isFinite(freqA) || !Number.isFinite(freqB)) + { + continue; + } + + let signedDelta = freqB - freqA; + if (bestSignedDelta == null || Math.abs(signedDelta) < Math.abs(bestSignedDelta)) + { + bestSignedDelta = signedDelta; + } + } + } + + return bestSignedDelta; + } + + GetMedian(numList) + { + if (!numList || numList.length == 0) + { + return null; + } + + let list = [...numList].sort((a, b) => a - b); + let idxMid = Math.floor(list.length / 2); + if (list.length % 2 == 1) + { + return list[idxMid]; + } + + return (list[idxMid - 1] + list[idxMid]) / 2; + } +} diff --git a/js/CandidateFilterBySpec.js b/js/CandidateFilterBySpec.js new file mode 100644 index 0000000..ab74486 --- /dev/null +++ b/js/CandidateFilterBySpec.js @@ -0,0 +1,284 @@ +/* +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 { CandidateFilterBase } from './CandidateFilterBase.js'; +import { NonRejectedOnlyFilter } from './WsprMessageCandidate.js'; + + +/////////////////////////////////////////////////////////////////////////// +// Candidate Filter - Spec +// +// Reject any messages which, by Extended Telemetry specification, +// do not belong. +/////////////////////////////////////////////////////////////////////////// + +export class CandidateFilterBySpec +extends CandidateFilterBase +{ + constructor(t) + { + super("BySpec", t); + } + + OnFilterStart() + { + this.t.Event(`CandidateFilterBySpec Start`); + } + + OnFilterEnd() + { + this.t.Event(`CandidateFilterBySpec End`); + } + + FilterWindowAlgorithm(msgListList) + { + this.FilterSlot0(msgListList[0]); + this.FilterSlot1(msgListList[1]); + this.FilterSlot2(msgListList[2]); + this.FilterSlot3(msgListList[3]); + this.FilterSlot4(msgListList[4]); + } + + +// private + + ///////////////////////////////////////////////////////////// + // Slot 0 Filter + // - Can have Regular Type 1 or Extended Telemetry + // - If there is Regular, prefer it over Extended + // - No Basic Telemetry allowed + ///////////////////////////////////////////////////////////// + FilterSlot0(msgList) + { + // First, reject any Basic Telemetry, if any + this.RejectCandidateBasicTelemetry(msgList, `Basic Telemetry not supported in Slot 0.`); + this.RejectExtendedTelemetryByHdrType(msgList, 2, `Expanded Basic Telemetry not supported in Slot 0.`); + this.RejectExtendedTelemetryByHdrType(msgList, 3, `HighResLocation not supported in Slot 0.`); + + // Collect what we see remaining + let msgRegularList = []; + let msgTelemetryList = []; + for (let msg of NonRejectedOnlyFilter(msgList)) + { + if (msg.IsRegular()) + { + msgRegularList.push(msg); + } + else if (msg.IsTelemetry()) + { + msgTelemetryList.push(msg); + } + } + + // Check what we found + if (msgRegularList.length == 0) + { + // no regular, that's fine, maybe extended telemetry is being used + + if (msgTelemetryList.length == 0) + { + // no extended telemetry found either. + + // that also means the contents of this slot are: + // - disqualified basic telemetry, if any + // - disqualified extended telemetry (eg being wrong slot, bad headers, etc) + // - nothing else + + // nothing to do here + } + else if (msgTelemetryList.length == 1) + { + // this is our guy + + // nothing to do, there are no other candidates to reject + } + else + { + // multiple candidates + + // nothing to do, no criteria by which to reject any of them + } + } + else if (msgRegularList.length == 1) + { + // this is our guy + + // mark any telemetry in this slot as rejected + let msgExcept = msgRegularList[0]; + this.RejectAllCandidatesByTypeExcept(msgList, + "telemetry", + msgExcept, + `Regular Type1 found in Slot 0, taking precedence.`); + } + else + { + // multiple Regular Type1 candidates -- that's bad for filtering + // could mean someone is transmitting from more than one location and the + // messages are all being received + + // no good way to reject any of the Regular Type1 messages in + // preference to any other, so they all remain candidates + + // mark any telemetry in this slot as rejected + let msgExcept = msgRegularList[0]; + this.RejectAllCandidatesByTypeExcept(msgList, + "telemetry", + msgExcept, + `Regular Type1 (multiple) found in Slot 0, taking precedence.`); + } + } + + ///////////////////////////////////////////////////////////// + // Slot 1 Filter + // - Can have Extended Telemetry or Basic Telemetry + // - If both, prefer Extended + ///////////////////////////////////////////////////////////// + FilterSlot1(msgList) + { + // Collect what we see remaining + let msgTelemetryExtendedList = []; + let msgTelemetryBasicList = []; + for (let msg of NonRejectedOnlyFilter(msgList)) + { + if (msg.IsTelemetryExtended()) + { + msgTelemetryExtendedList.push(msg); + } + else if (msg.IsTelemetryBasic()) + { + msgTelemetryBasicList.push(msg); + } + } + + // Check what we found + if (msgTelemetryExtendedList.length == 0) + { + // no extended, that's fine, maybe basic telemetry is being used + + if (msgTelemetryBasicList.length == 0) + { + // no basic telemetry found either. + + // nothing to do here + } + else if (msgTelemetryBasicList.length == 1) + { + // this is our guy + + // nothing to do, there are no other candidates to reject + } + else + { + // multiple candidates + + // nothing to do, no criteria by which to reject any of them + } + } + else if (msgTelemetryExtendedList.length == 1) + { + // this is our guy + + // mark any basic telemetry in this slot as rejected + let msgExcept = msgTelemetryExtendedList[0]; + this.RejectAllTelemetryCandidatesByTypeExcept(msgList, + "basic", + msgExcept, + `Extended Telemetry found in Slot 1, taking precedence.`); + } + else + { + // multiple Extended Telemetry candidates + + // no good way to reject any of the Regular Type1 messages in + // preference to any other, so they all remain candidates + + // mark any telemetry in this slot as rejected + let msgExcept = msgTelemetryExtendedList[0]; + this.RejectAllTelemetryCandidatesByTypeExcept(msgList, + "basic", + msgExcept, + `Extended Telemetry (multiple) found in Slot 1, taking precedence.`); + } + } + + ///////////////////////////////////////////////////////////// + // Slot 2 Filter + // - Can only have Extended Telemetry + ///////////////////////////////////////////////////////////// + FilterSlot2(msgList) + { + this.RejectCandidateBasicTelemetry(msgList, `Basic Telemetry not supported in Slot 2.`); + } + + ///////////////////////////////////////////////////////////// + // Slot 3 Filter + // - Can only have Extended Telemetry + ///////////////////////////////////////////////////////////// + FilterSlot3(msgList) + { + this.RejectCandidateBasicTelemetry(msgList, `Basic Telemetry not supported in Slot 3.`); + } + + ///////////////////////////////////////////////////////////// + // Slot 4 Filter + // - Can only have Extended Telemetry + ///////////////////////////////////////////////////////////// + FilterSlot4(msgList) + { + this.RejectCandidateBasicTelemetry(msgList, `Basic Telemetry not supported in Slot 4.`); + } + + + ///////////////////////////////////////////////////////////// + // Helper utilities + ///////////////////////////////////////////////////////////// + + RejectCandidateBasicTelemetry(msgList, reason) + { + for (let msg of NonRejectedOnlyFilter(msgList)) + { + if (msg.IsTelemetryBasic()) + { + msg.Reject(this.type, reason); + } + } + }; + + RejectExtendedTelemetryByHdrType(msgList, hdrType, reason) + { + for (let msg of NonRejectedOnlyFilter(msgList)) + { + if (msg.IsTelemetryExtended() && msg.GetCodec?.()?.GetHdrTypeEnum?.() == hdrType) + { + msg.Reject(this.type, reason); + } + } + }; + + RejectAllCandidatesByTypeExcept(msgList, type, msgExcept, reason) + { + for (let msg of NonRejectedOnlyFilter(msgList)) + { + if (msg.IsType(type) && msg != msgExcept) + { + msg.Reject(this.type, reason); + } + } + }; + + RejectAllTelemetryCandidatesByTypeExcept(msgList, type, msgExcept, reason) + { + for (let msg of NonRejectedOnlyFilter(msgList)) + { + if (msg.IsTelemetryType(type) && msg != msgExcept) + { + msg.Reject(this.type, reason); + } + } + }; +} diff --git a/js/CandidateFilterConfirmed.js b/js/CandidateFilterConfirmed.js new file mode 100644 index 0000000..218136b --- /dev/null +++ b/js/CandidateFilterConfirmed.js @@ -0,0 +1,62 @@ +/* +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 { CandidateFilterBase } from './CandidateFilterBase.js'; +import { NonRejectedOnlyFilter } from './WsprMessageCandidate.js'; + + +/////////////////////////////////////////////////////////////////////////// +// Candidate Filter - Confirmed +// +// If a slot contains one or more confirmed messages, reject any remaining +// non-confirmed candidates in that same slot. +/////////////////////////////////////////////////////////////////////////// + +export class CandidateFilterConfirmed +extends CandidateFilterBase +{ + constructor(t) + { + super("ByConfirmed", t); + } + + OnFilterStart() + { + this.t.Event(`CandidateFilterConfirmed Start`); + } + + OnFilterEnd() + { + this.t.Event(`CandidateFilterConfirmed End`); + } + + FilterWindowAlgorithm(msgListList) + { + for (let slot = 0; slot < 5; ++slot) + { + let msgList = NonRejectedOnlyFilter(msgListList[slot]); + let hasConfirmed = msgList.some(msg => msg.IsConfirmed()); + + if (!hasConfirmed) + { + continue; + } + + for (let msg of msgList) + { + if (msg.IsCandidate()) + { + msg.Reject( + this.type, + `Confirmed message found in slot ${slot}, rejecting unconfirmed candidates in same slot.` + ); + } + } + } + } +} diff --git a/js/CandidateFilterHeartbeat.js b/js/CandidateFilterHeartbeat.js new file mode 100644 index 0000000..d49ab09 --- /dev/null +++ b/js/CandidateFilterHeartbeat.js @@ -0,0 +1,91 @@ +/* +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 { CandidateFilterBase } from './CandidateFilterBase.js'; +import { NonRejectedOnlyFilter } from './WsprMessageCandidate.js'; +import { CodecHeartbeat } from './CodecHeartbeat.js'; +import { WSPR } from '/js/WSPR.js'; + + +/////////////////////////////////////////////////////////////////////////// +// +// Candidate Filter - Heartbeat +// +// Reject Heartbeat messages whose stated intended TX frequency does not +// match the searched-for channel frequency. +/////////////////////////////////////////////////////////////////////////// + +export class CandidateFilterHeartbeat +extends CandidateFilterBase +{ + constructor(t, band, channel) + { + super("Heartbeat", t); + + this.band = band; + this.channel = channel; + this.codecHeartbeat = new CodecHeartbeat(); + } + + OnFilterStart() + { + this.t.Event(`CandidateFilterHeartbeat Start`); + } + + OnFilterEnd() + { + this.t.Event(`CandidateFilterHeartbeat End`); + } + + FilterWindowAlgorithm(msgListList) + { + if (this.channel == "") + { + return; + } + + let searchedFreqHz = WSPR.GetChannelDetails(this.band, this.channel).freq; + + for (let slot = 0; slot < 5; ++slot) + { + for (let msg of NonRejectedOnlyFilter(msgListList[slot])) + { + if (!msg.IsTelemetryExtended()) + { + continue; + } + + let codec = msg.GetCodec(); + if (!this.codecHeartbeat.IsCodecHeartbeat(codec)) + { + continue; + } + + let intendedFreqHz = this.codecHeartbeat.DecodeTxFreqHzFromBand( + this.band, + codec.GetTxFreqHzIdx(), + ); + + if (intendedFreqHz !== searchedFreqHz) + { + msg.Reject( + this.type, + `Heartbeat intended frequency (${intendedFreqHz}) does not match searched channel frequency (${searchedFreqHz}).` + ); + } + else if (msg.IsCandidate()) + { + msg.Confirm( + this.type, + `Heartbeat matches searched channel frequency (${searchedFreqHz}).` + ); + } + } + } + } +} diff --git a/js/CandidateFilterHighResLocation.js b/js/CandidateFilterHighResLocation.js new file mode 100644 index 0000000..4826fb1 --- /dev/null +++ b/js/CandidateFilterHighResLocation.js @@ -0,0 +1,69 @@ +/* +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 { CandidateFilterBase } from './CandidateFilterBase.js'; +import { NonRejectedOnlyFilter } from './WsprMessageCandidate.js'; + + +/////////////////////////////////////////////////////////////////////////// +// Candidate Filter - HighResLocation +// +// Reject HighResLocation messages whose Reference field is not an +// established value. +/////////////////////////////////////////////////////////////////////////// + +export class CandidateFilterHighResLocation +extends CandidateFilterBase +{ + constructor(t) + { + super("HighResLocation", t); + } + + OnFilterStart() + { + this.t.Event(`CandidateFilterHighResLocation Start`); + } + + OnFilterEnd() + { + this.t.Event(`CandidateFilterHighResLocation End`); + } + + FilterWindowAlgorithm(msgListList) + { + for (let slot = 0; slot < 5; ++slot) + { + for (let msg of NonRejectedOnlyFilter(msgListList[slot])) + { + if (!msg.IsTelemetryExtended()) + { + continue; + } + + let codec = msg.GetCodec(); + if (codec.GetHdrTypeEnum() != 3) + { + continue; + } + + if (slot == 0) + { + msg.Reject(this.type, `HighResLocation is not supported in Slot 0.`); + continue; + } + + let referenceEnum = codec.GetReferenceEnum(); + if (referenceEnum != 1) + { + msg.Reject(this.type, `HighResLocation Reference (${referenceEnum}) is not an established value.`); + } + } + } + } +} diff --git a/js/Chart.js b/js/Chart.js new file mode 100644 index 0000000..4cfc43a --- /dev/null +++ b/js/Chart.js @@ -0,0 +1,1886 @@ +/* +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 { AsyncResourceLoader } from './AsyncResourceLoader.js'; +import { Base } from './Base.js'; + + +/////////////////////////////////////////////////////////////////////////////// +// Chart Base +/////////////////////////////////////////////////////////////////////////////// + +class ChartBase +extends Base +{ + static urlEchartsScript = `https://fastly.jsdelivr.net/npm/echarts@5.4.2/dist/echarts.min.js`; + + static urlDatGuiScript = `https://cdn.jsdelivr.net/npm/dat.gui@0.7.9/build/dat.gui.min.js`; + static urlDatGuiCss = `https://cdn.jsdelivr.net/npm/dat.gui@0.7.9/build/dat.gui.min.css`; + + constructor() + { + super(); + + this.ui = this.MakeUI(); + this.data = null; + + this.chart = echarts.init(this.ui); + + this.HandleResizing(); + + this.resourcesOutstanding = 0; + this.LoadResources(); + } + + Ok() + { + return this.resourcesOutstanding == 0; + } + + HandleResizing() + { + // This is very smooth, except when the resizing causes other page + // elements to move (especially big ones). + let isResizing = false; + const resizeObserver = new ResizeObserver(() => { + if (!isResizing) + { + isResizing = true; + + window.requestAnimationFrame(() => { + this.chart.resize(); + isResizing = false; + }); + } + }); + resizeObserver.observe(this.ui); + } + + static GetExternalScriptResourceUrlList() + { + return [ + ChartBase.urlEchartsScript, + ChartBase.urlDatGuiScript, + ]; + } + + static GetExternalStylesheetResourceUrlList() + { + return [ + ChartBase.urlDatGuiCss, + ]; + } + + // This loads external resources on page load instead of when the chart is activated. + // ECharts took 150ms+ to load. + static PreLoadExternalResources() + { + let urlScriptList = []; + urlScriptList.push(... ChartBase.GetExternalScriptResourceUrlList()); + for (const url of urlScriptList) + { + AsyncResourceLoader.AsyncLoadScript(url); + } + + let urlStylesheetList = []; + urlStylesheetList.push(... ChartBase.GetExternalStylesheetResourceUrlList()); + for (const url of urlStylesheetList) + { + AsyncResourceLoader.AsyncLoadStylesheet(url); + } + } + + GetUI() + { + return this.ui; + } + + PlotData(data) + { + // This can happen before, during, or after all external resources are loaded. + // In the event not all resources loaded, cache the data. + this.data = data; + + if (this.resourcesOutstanding == 0) + { + this.PlotDataNow(this.data); + } + } + + +// private + + MakeUI() + { + this.ui = document.createElement('div'); + + this.ui.innerHTML = "Chart" + this.ui.style.boxSizing = "border-box"; + this.ui.style.border = "1px solid black"; + this.ui.style.height = "300px"; + // this.ui.style.height = "30vh"; + this.ui.style.minHeight = "250px"; + + this.ui.style.resize = "both"; + this.ui.style.overflow = "hidden"; // tooltips do this + + return this.ui; + } + + PlotDataNow(data) + { + // placeholder for inheriting classes to implement + } + + LoadResources() + { + // script is critical, must wait for it to load + for (const url of ChartBase.GetExternalScriptResourceUrlList()) + { + this.AsyncLoadScriptAndPlotIfAllComplete(url); + } + + // css is not critical, load (or not), but we continue + for (const url of ChartBase.GetExternalStylesheetResourceUrlList()) + { + AsyncResourceLoader.AsyncLoadStylesheet(url); + } + } + + async AsyncLoadScriptAndPlotIfAllComplete(url) + { + try + { + ++this.resourcesOutstanding; + + await AsyncResourceLoader.AsyncLoadScript(url); + + --this.resourcesOutstanding; + } + catch (e) + { + this.Err(`Chart`, `Could not load ${url} - ${e}.`) + } + + // check if cached data to plot + if (this.data && this.resourcesOutstanding == 0) + { + this.PlotDataNow(this.data); + } + } +} + +export function PreLoadChartExternalResources() +{ + ChartBase.PreLoadExternalResources(); +} + + + + + + + + + + + + +/////////////////////////////////////////////////////////////////////////////// +// ECharts Utils - just factoring out some common functionality +/////////////////////////////////////////////////////////////////////////////// + +class EChartsUtils +{ + static GetUseSymbolForCurrentZoom(chart) + { + const axisInfo = chart.getModel().getComponent('xAxis').axis; + const [startValue, endValue] = axisInfo.scale.getExtent(); + + let MS_IN_24_HOURS = 24 * 60 * 60 * 1000; + let MS_IN_3_DAYS = MS_IN_24_HOURS * 3; + + return ((endValue - startValue) <= MS_IN_3_DAYS); + } + + static XAxisFormatter(params) + { + // convert the ms time value into human-readable + let ts = utl.MakeDateTimeFromMs(params.value); + + // last char is could be an odd minute, let's eliminate that + let lastChar = ts.charAt(ts.length - 1); + if ("02468".indexOf(lastChar) == -1) + { + let lastCharNew = String.fromCharCode(lastChar.charCodeAt(0) - 1); + + ts = ts.substring(0, ts.length - 1) + lastCharNew; + } + + return ts; + }; + + static Pointer(params) + { + return EChartsUtils.RoundCommas(params.value); + }; + + static RoundCommas(val) + { + return utl.Commas(Math.round(val)); + } + + static OnZoomPan(chart) + { + let useSymbol = this.GetUseSymbolForCurrentZoom(chart); + + let seriesCfgList = []; + for (let series in chart.getOption().series) + { + seriesCfgList.push({ + symbol: useSymbol ? "circle" : "none", + symbolSize: 4, + }); + } + // apply updated value + chart.setOption({ + series: seriesCfgList, + }); + }; +} + + + + + + +/////////////////////////////////////////////////////////////////////////////// +// ChartTimeSeriesBase +// +// https://echarts.apache.org/en/option.html +/////////////////////////////////////////////////////////////////////////////// + +export class ChartTimeSeriesBase +extends ChartBase +{ + constructor() + { + super(); + + this.idRxGetZoom = null; + this.idRxSetZoom = null; + this.isApplyingSyncedZoom = false; + this.isDraggingZoom = false; + this.useSymbolForCurrentZoom = null; + this.lastZoomEmitMs = 0; + this.zoomSyncThrottleMs = 24; + this.zoomEmitTimeoutId = null; + this.wheelZoomDebounceMs = 40; + } + + OnEvent(evt) + { + if (this.Ok()) + { + switch (evt.type) { + case "TIME_SERIES_SET_ZOOM": this.OnSetZoom(evt); break; + } + } + } + + OnSetZoom(evt) + { + if (evt.origin != this) + { + // make all the charts zoom asynchronously, which reduces jank a lot + + // cache the latest data for the next time your callback fires. + // has the effect of doing update accumulation. + this.evtSetZoom = evt; + + if (this.idRxSetZoom == null) + { + this.idRxSetZoom = window.requestAnimationFrame(() => { + this.isApplyingSyncedZoom = true; + this.chart.dispatchAction({ + type: "dataZoom", + startValue: this.evtSetZoom.startValue, + endValue: this.evtSetZoom.endValue, + }); + this.#UpdateSymbolVisibilityForZoom(); + + this.isApplyingSyncedZoom = false; + this.idRxSetZoom = null; + }); + } + } + } + + // Plot any number of series in a single chart. + // + // Expects data to have the format: + // { + // td: TabularData, + // + // xAxisDetail: { + // column: "DateTimeLocal", + // }, + // + // // put all series on two axes, or one + // yAxisMode: "two", + // + // yAxisDetailList: [ + // { + // column: "Voltage", + // min : 3 + // max : 4.95 + // }, + // ... + // ], + // } + PlotDataNow(data) + { + let td = data.td; + + // cache + let timeCol = data.xAxisDetail.column; + + // get series data + let seriesDataList = this.GetSeriesDataList(data); + + // create chart options + let option = {}; + + // x-axis options + option.xAxis = this.GetOptionXAxis(); + + // zoom options + option.dataZoom = this.GetOptionDataZoom(); + + // y-axis options + option.yAxis = this.GetOptionYAxis(data); + + // series options + option.series = this.GetOptionSeries(data, seriesDataList); + + // tooltip options + option.tooltip = this.GetOptionTooltip(data, seriesDataList); + + // animation options + option.animation = this.GetOptionAnimation(); + + // grid options + option.grid = this.GetOptionGrid(); + + // legend options + option.legend = this.GetOptionLegend(); + + this.OnPrePlot(option); + + // plot + this.chart.setOption(option, true); + + // apply initial zoom/pan-based logic + this.#UpdateSymbolVisibilityForZoom(true); + + // handle zoom/pan, and let others join in on the zoom fun + this.chart.on('dataZoom', () => { + if (this.isApplyingSyncedZoom) + { + return; + } + + const axisInfo = this.chart.getModel().getComponent('xAxis').axis; + const [startValue, endValue] = axisInfo.scale.getExtent(); + + // cache the latest data for the next time your callback fires. + // has the effect of doing update accumulation. + this.evtGetZoom = { + type: "TIME_SERIES_SET_ZOOM", + origin: this, + startValue, + endValue, + } + + if (this.idRxGetZoom == null) + { + this.idRxGetZoom = window.requestAnimationFrame(() => { + if (!this.isDraggingZoom) + { + this.#UpdateSymbolVisibilityForZoom(); + } + + if (!this.isDraggingZoom) + { + this.#ScheduleTrailingZoomSync(this.wheelZoomDebounceMs); + } + else + { + let now = performance.now(); + let msSinceLastEmit = now - this.lastZoomEmitMs; + let shouldEmit = msSinceLastEmit >= this.zoomSyncThrottleMs; + + if (shouldEmit) + { + this.#EmitZoomSyncNow(); + } + else if (this.zoomEmitTimeoutId == null) + { + let msDelay = Math.max(0, this.zoomSyncThrottleMs - msSinceLastEmit); + this.#ScheduleTrailingZoomSync(msDelay); + } + } + + this.idRxGetZoom = null; + }); + } + }); + + // reduce jank when dragging the chart + this.chart.getZr().on('mousedown', () => { + this.hideTooltip = true; + this.isDraggingZoom = true; + }); + this.chart.getZr().on('mouseup', () => { + this.hideTooltip = false; + this.isDraggingZoom = false; + window.requestAnimationFrame(() => { + this.#UpdateSymbolVisibilityForZoom(); + this.#EmitZoomSyncNow(); + }); + }); + this.chart.getZr().on('globalout', () => { + this.hideTooltip = false; + this.isDraggingZoom = false; + }); + } + + #EmitZoomSyncNow() + { + if (!this.evtGetZoom) + { + return; + } + + if (this.zoomEmitTimeoutId != null) + { + window.clearTimeout(this.zoomEmitTimeoutId); + this.zoomEmitTimeoutId = null; + } + + this.lastZoomEmitMs = performance.now(); + this.Emit(this.evtGetZoom); + } + + #ScheduleTrailingZoomSync(msDelay) + { + if (this.zoomEmitTimeoutId != null) + { + window.clearTimeout(this.zoomEmitTimeoutId); + } + + this.zoomEmitTimeoutId = window.setTimeout(() => { + this.zoomEmitTimeoutId = null; + if (this.evtGetZoom) + { + this.#EmitZoomSyncNow(); + } + }, msDelay); + } + + #UpdateSymbolVisibilityForZoom(force = false) + { + let useSymbol = EChartsUtils.GetUseSymbolForCurrentZoom(this.chart); + if (!force && this.useSymbolForCurrentZoom === useSymbol) + { + return; + } + + this.useSymbolForCurrentZoom = useSymbol; + EChartsUtils.OnZoomPan(this.chart); + } + + OnPrePlot(option) + { + // do nothing, this is for inheriting classes + } + + GetSeriesDataList(data) + { + let td = data.td; + let timeCol = data.xAxisDetail.column; + + // get series data + let seriesDataList = []; + for (const yAxisDetail of data.yAxisDetailList) + { + let seriesData = td.ExtractDataOnly([timeCol, yAxisDetail.column]); + + seriesDataList.push(seriesData); + } + + return seriesDataList; + } + + GetOptionXAxis() + { + return { + type: "time", + axisPointer: { + show: true, + label: { + formatter: EChartsUtils.XAxisFormatter, + }, + }, + axisLabel: { + formatter: { + day: "{MMM} {d}", + }, + }, + }; + } + + GetOptionDataZoom() + { + return [ + { + type: 'inside', + filterMode: "none", + throttle: 16, + }, + ]; + } + + GetOptionYAxis(data) + { + let yAxisObjList = []; + for (let i = 0; i < data.yAxisDetailList.length; ++i) + { + let obj = { + type: "value", + name: data.yAxisDetailList[i].column, + + // only show y-axis split from first y-axis + splitLine: { + show: i ? false : true, + }, + + axisPointer: { + show: true, + label: { + formatter: EChartsUtils.Pointer, + }, + }, + axisLabel: { + // formatter: EChartsUtils.RoundCommas, + }, + }; + + let min = data.yAxisDetailList[i].min; + let max = data.yAxisDetailList[i].max; + if (i == 0) + { + // first series always on the left-axis + + if (min != undefined) { obj.min = min; } + if (max != undefined) { obj.max = max; } + } + else + { + if (data.yAxisMode == "one") + { + // can also assign the right-side y-axis to be the same values as left + // if that looks nicer + // obj.min = data.yAxisDetailList[0].min; + // obj.max = data.yAxisDetailList[0].max; + } + else + { + // use the specified min/max for this series + if (min != undefined) { obj.min = min; } + if (max != undefined) { obj.max = max; } + } + } + + yAxisObjList.push(obj); + } + + return yAxisObjList; + } + + GetOptionSeries(data, seriesDataList) + { + let seriesObjList = []; + for (let i = 0; i < data.yAxisDetailList.length; ++i) + { + let obj = { + name: data.yAxisDetailList[i].column, + type: "line", + + yAxisIndex: data.yAxisMode == "one" ? 0 : i, + + data: seriesDataList[i], + connectNulls: true, + }; + + if (seriesDataList[i].length >= 1) + { + obj.symbol = "none"; + } + + seriesObjList.push(obj); + } + + return seriesObjList; + } + + GetOptionTooltip(data, seriesDataList) + { + return { + show: true, + trigger: "axis", + confine: true, + formatter: params => { + let retVal = undefined; + + // reduces jank when dragging the chart + if (this.hideTooltip) { return retVal; } + + let idx = params[0].dataIndex; + + let msg = ``; + + let sep = ``; + let countWithVal = 0; + for (let i = 0; i < data.yAxisDetailList.length; ++i) + { + let col = data.yAxisDetailList[i].column; + let val = seriesDataList[i][idx][1]; + + if (val == undefined) + { + val = ""; + } + else + { + ++countWithVal; + + val = utl.Commas(val); + } + + msg += sep; + msg += `${col}: ${val}`; + + sep = `
`; + } + + msg += `
`; + msg += `
`; + msg += params[0].data[0]; // timestamp + + if (countWithVal) + { + retVal = msg; + } + + return retVal; + }, + }; + } + + GetOptionAnimation() + { + return false; + } + + GetOptionGrid() + { + return { + top: "40px", + left: "50px", + bottom: "30px", + }; + } + + GetOptionLegend() + { + return { + show: true, + }; + } +} + + +/////////////////////////////////////////////////////////////////////////////// +// ChartTimeSeries +/////////////////////////////////////////////////////////////////////////////// + +export class ChartTimeSeries +extends ChartTimeSeriesBase +{ + constructor() + { + super(); + } + + OnPrePlot(option) + { + // virtual + } +} + +/////////////////////////////////////////////////////////////////////////////// +// ChartTimeSeriesBar +/////////////////////////////////////////////////////////////////////////////// + +export class ChartTimeSeriesBar +extends ChartTimeSeriesBase +{ + constructor() + { + super(); + } + + GetOptionSeries(data, seriesDataList) + { + let seriesObjList = []; + for (let i = 0; i < data.yAxisDetailList.length; ++i) + { + seriesObjList.push({ + name: data.yAxisDetailList[i].column, + type: "bar", + yAxisIndex: data.yAxisMode == "one" ? 0 : i, + data: seriesDataList[i], + barMaxWidth: 24, + }); + } + + return seriesObjList; + } +} + +/////////////////////////////////////////////////////////////////////////////// +// ChartHistogramBar +/////////////////////////////////////////////////////////////////////////////// + +export class ChartHistogramBar +extends ChartBase +{ + constructor() + { + super(); + } + + PlotDataNow(data) + { + let bucketLabelList = data.bucketLabelList || []; + let bucketCountList = data.bucketCountList || []; + let xAxisName = data.xAxisName || ""; + let yAxisName = data.yAxisName || ""; + let grid = data.grid || { + top: 30, + left: 34, + right: 14, + bottom: 56, + }; + let xAxisNameGap = data.xAxisNameGap ?? 42; + let xAxisLabelRotate = data.xAxisLabelRotate ?? 45; + let xAxisLabelMargin = data.xAxisLabelMargin ?? 6; + let yAxisNameGap = data.yAxisNameGap ?? 10; + + this.chart.setOption({ + grid, + xAxis: { + type: "category", + data: bucketLabelList, + name: xAxisName, + nameLocation: "middle", + nameGap: xAxisNameGap, + axisLabel: { + interval: 1, + rotate: xAxisLabelRotate, + fontSize: 10, + margin: xAxisLabelMargin, + showMaxLabel: true, + hideOverlap: false, + }, + }, + yAxis: { + type: "value", + name: yAxisName, + nameGap: yAxisNameGap, + min: 0, + minInterval: 1, + }, + tooltip: { + trigger: "axis", + }, + series: [ + { + type: "bar", + data: bucketCountList, + barMaxWidth: 20, + }, + ], + animation: false, + }, true); + } +} + + +/////////////////////////////////////////////////////////////////////////////// +// ChartTimeSeriesTwoSeriesOneLine +// +// Specialty class for plotting (say) the same value in both +// Metric and Imperial. +// +// Overcomes the problem that plotting the same (but converted units) series +// on the same plot _almost_ works, but has tiny imperfections where the lines +// don't perfectly overlap. +/////////////////////////////////////////////////////////////////////////////// + +export class ChartTimeSeriesTwoEqualSeriesOneLine +extends ChartTimeSeriesBase +{ + constructor() + { + super(); + } + + OnPrePlot(option) + { + if (option.series.length >= 1) + { + delete option.series[1].data; + } + + option.legend = false; + } +} + +/////////////////////////////////////////////////////////////////////////////// +// ChartTimeSeriesTwoEqualSeriesOneLinePlus +// +// Specialty class for plotting: +// - the same value in both (say) Metric and Imperial +// - then two extra series, which gets no credit on the y-axis +// +// The chart axes are: +// - left y-axis values : series 0 +// - left y-axis min/max: series 2 +// - right y-axis values : series 1 +// - right y-axis min/max: series 3 +/////////////////////////////////////////////////////////////////////////////// + +export class ChartTimeSeriesTwoEqualSeriesOneLinePlus +extends ChartTimeSeriesTwoEqualSeriesOneLine +{ + constructor() + { + super(); + } + + OnPrePlot(option) + { + super.OnPrePlot(option); + + if (option.series.length >= 4) + { + // we overwrite the 2nd series configuration (which we don't want to plot anyway) + // and move it to the first y-axis + option.series[1].yAxisIndex = 0; + option.series[1].data = option.series[2].data; + + // update axes + option.yAxis[0].min = option.yAxis[2].min; + option.yAxis[0].max = option.yAxis[2].max; + + option.yAxis[1].min = option.yAxis[3].min; + option.yAxis[1].max = option.yAxis[3].max; + + // we destroy the 3rd+ series data so the chart ignores it + option.series.length = 2; + option.yAxis.length = 2; + } + } +} + + +/////////////////////////////////////////////////////////////////////////////// +// ChartScatterSeriesPicker +// +// Scatter plot any two selected numeric series against each other. +/////////////////////////////////////////////////////////////////////////////// + +export class ChartScatterSeriesPicker +extends ChartBase +{ + static COOKIE_MODE = "wspr_scatter_mode"; + static COOKIE_X = "wspr_scatter_x_series"; + static COOKIE_Y = "wspr_scatter_y_series"; + static COOKIE_HISTOGRAM_SERIES_SETTINGS = "wspr_histogram_series_settings"; + static COOKIE_GUI_CLOSED = "wspr_scatter_gui_closed"; + + constructor() + { + super(); + + this.gui = null; + this.guiState = { + mode: "Scatter", + xSeries: "", + ySeries: "", + bucketSize: "", + minValue: "", + maxValue: "", + }; + this.histogramSeriesSettingsByName = {}; + + this.ui.style.position = "relative"; + } + + PlotDataNow(data) + { + this.data = data; + + let td = data.td; + let colNameList = (data.colNameList || []).filter(col => td.Idx(col) != undefined); + + if (colNameList.length < 1) + { + this.chart.setOption({ + title: { text: "Scatter / Histogram: need at least one series" }, + }, true); + return; + } + + let preferredXList = this.NormalizePreferredList(data.preferredXSeriesList ?? data.preferredXSeries); + let preferredYList = this.NormalizePreferredList(data.preferredYSeriesList ?? data.preferredYSeries); + let resolved = this.ResolveInitialSelection(colNameList, preferredXList, preferredYList); + this.#LoadHistogramSeriesSettingsFromCookie(); + this.guiState.mode = this.ResolveInitialMode(); + this.guiState.xSeries = resolved.xSeries; + this.guiState.ySeries = resolved.ySeries; + this.#ApplyHistogramSeriesSettings(this.guiState.xSeries); + + this.SetupGui(colNameList); + this.RenderCurrentMode(); + } + + SetupGui(colNameList) + { + if (typeof dat == "undefined" || !dat.GUI) + { + return; + } + + if (this.gui) + { + this.gui.destroy(); + this.gui = null; + } + + this.gui = new dat.GUI({ autoPlace: false, width: 220 }); + this.gui.domElement.style.position = "absolute"; + this.gui.domElement.style.top = "4px"; + this.gui.domElement.style.right = "4px"; + this.gui.domElement.style.left = "auto"; + this.gui.domElement.style.zIndex = "2"; + this.#UpdateGuiLayout(colNameList); + this.ui.appendChild(this.gui.domElement); + this.#InstallGuiOpenCloseButton(); + + let modeController = this.gui.add(this.guiState, "mode", ["Scatter", "Histogram"]).name("Mode").onChange(() => { + this.WriteCookie(ChartScatterSeriesPicker.COOKIE_MODE, this.guiState.mode); + this.#UpdateModeVisibility(); + this.RenderCurrentMode(); + }); + + let yController = this.gui.add(this.guiState, "ySeries", colNameList).name("Y Series").onChange(() => { + this.OnAxisSelectionChanged("y", colNameList); + }); + + let xController = this.gui.add(this.guiState, "xSeries", colNameList).name("X Series").onChange(() => { + this.OnAxisSelectionChanged("x", colNameList); + }); + + let bucketController = this.gui.add(this.guiState, "bucketSize").name("Bucket Size").onFinishChange(() => { + this.NormalizeBucketSize(); + this.#PersistCurrentHistogramSeriesSettings(); + this.bucketController?.updateDisplay(); + this.RenderCurrentMode(); + }); + + let minController = this.gui.add(this.guiState, "minValue").name("Min").onFinishChange(() => { + this.NormalizeRangeBounds(); + this.#PersistCurrentHistogramSeriesSettings(); + this.minController?.updateDisplay(); + this.maxController?.updateDisplay(); + this.RenderCurrentMode(); + }); + + let maxController = this.gui.add(this.guiState, "maxValue").name("Max").onFinishChange(() => { + this.NormalizeRangeBounds(); + this.#PersistCurrentHistogramSeriesSettings(); + this.minController?.updateDisplay(); + this.maxController?.updateDisplay(); + this.RenderCurrentMode(); + }); + + this.#ApplyDatGuiControllerLayout(modeController); + this.#ApplyDatGuiControllerLayout(xController); + this.#ApplyDatGuiControllerLayout(yController); + this.#ApplyDatGuiControllerLayout(bucketController); + this.#ApplyDatGuiControllerLayout(minController); + this.#ApplyDatGuiControllerLayout(maxController); + + this.modeController = modeController; + this.xController = xController; + this.yController = yController; + this.bucketController = bucketController; + this.minController = minController; + this.maxController = maxController; + + this.#HookGuiOpenClosePersistence(); + this.#ApplyGuiOpenClosePreference(); + this.#UpdateModeVisibility(); + } + + OnAxisSelectionChanged(axisChanged, colNameList) + { + // Keep both visible selectors synchronized when one changes the other. + this.xController?.updateDisplay(); + this.yController?.updateDisplay(); + + this.WriteCookie(ChartScatterSeriesPicker.COOKIE_X, this.guiState.xSeries); + this.WriteCookie(ChartScatterSeriesPicker.COOKIE_Y, this.guiState.ySeries); + this.#ApplyHistogramSeriesSettings(this.guiState.xSeries); + this.bucketController?.updateDisplay(); + this.minController?.updateDisplay(); + this.maxController?.updateDisplay(); + this.RenderCurrentMode(); + } + + #UpdateGuiLayout(colNameList) + { + // Estimate width from the longest selectable series name, then clamp + // so the GUI never renders outside the chart while staying right-aligned. + let longest = 0; + for (const colName of colNameList) + { + longest = Math.max(longest, String(colName).length); + } + + let widthByText = 155 + (longest * 7); + let desiredWidth = Math.min(500, Math.max(240, widthByText)); + + let chartInnerWidth = Math.max(0, this.ui.clientWidth - 10); + let widthUse = Math.max(190, Math.min(desiredWidth, chartInnerWidth)); + + this.gui.width = widthUse; + this.gui.domElement.style.width = `${widthUse}px`; + this.gui.domElement.style.maxWidth = "calc(100% - 8px)"; + } + + #ApplyDatGuiControllerLayout(controller) + { + if (!controller || !controller.domElement) + { + return; + } + + let row = controller.domElement; + let nameEl = row.querySelector(".property-name"); + let controlEl = row.querySelector(".c"); + let inputEl = row.querySelector("select, input"); + + if (nameEl) + { + nameEl.style.width = "92px"; + } + if (controlEl) + { + controlEl.style.width = "calc(100% - 92px)"; + controlEl.style.boxSizing = "border-box"; + controlEl.style.paddingRight = "2px"; + } + if (inputEl) + { + inputEl.style.width = "100%"; + inputEl.style.boxSizing = "border-box"; + inputEl.style.maxWidth = "100%"; + } + } + + #UpdateModeVisibility() + { + let scatterMode = this.guiState.mode == "Scatter"; + let setDisplay = (controller, visible) => { + let row = controller?.domElement?.closest?.("li") || controller?.domElement; + if (!row) + { + return; + } + + row.hidden = !visible; + row.style.display = visible ? "" : "none"; + row.style.visibility = visible ? "" : "hidden"; + row.style.height = visible ? "" : "0"; + row.style.minHeight = visible ? "" : "0"; + row.style.margin = visible ? "" : "0"; + row.style.padding = visible ? "" : "0"; + row.style.border = visible ? "" : "0"; + row.style.overflow = visible ? "" : "hidden"; + }; + + setDisplay(this.yController, scatterMode); + setDisplay(this.bucketController, !scatterMode); + setDisplay(this.minController, !scatterMode); + setDisplay(this.maxController, !scatterMode); + } + + RenderCurrentMode() + { + if (this.guiState.mode == "Histogram") + { + this.RenderHistogram(); + } + else + { + this.RenderScatter(); + } + } + + RenderScatter() + { + if (!this.data || !this.data.td) + { + return; + } + + if (this.gui) + { + this.#UpdateGuiLayout(this.data.colNameList || []); + } + + let td = this.data.td; + let xSeries = this.guiState.xSeries; + let ySeries = this.guiState.ySeries; + + if (!xSeries || !ySeries) + { + this.chart.setOption({ + title: { text: "Scatter: need at least two series" }, + }, true); + return; + } + + let pairListRaw = td.ExtractDataOnly([xSeries, ySeries]); + let pairList = []; + + for (const pair of pairListRaw) + { + if (pair[0] == null || pair[0] === "" || pair[1] == null || pair[1] === "") + { + continue; + } + + let xVal = Number(pair[0]); + let yVal = Number(pair[1]); + + if (Number.isFinite(xVal) && Number.isFinite(yVal)) + { + pairList.push([xVal, yVal]); + } + } + + this.chart.setOption({ + grid: { + top: 30, + left: 60, + right: 30, + bottom: 40, + }, + xAxis: { + type: "value", + name: xSeries, + nameLocation: "middle", + nameGap: 28, + axisLabel: { + formatter: EChartsUtils.RoundCommas, + }, + }, + yAxis: { + type: "value", + name: ySeries, + nameLocation: "middle", + nameGap: 45, + axisLabel: { + formatter: EChartsUtils.RoundCommas, + }, + }, + tooltip: { + trigger: "item", + formatter: params => `${xSeries}: ${utl.Commas(params.value[0])}
${ySeries}: ${utl.Commas(params.value[1])}`, + }, + series: [ + { + type: "scatter", + symbolSize: 6, + data: pairList, + }, + ], + animation: false, + }, true); + } + + ResolveInitialSelection(colNameList, preferredXList, preferredYList) + { + let cookieX = this.ReadCookie(ChartScatterSeriesPicker.COOKIE_X); + let cookieY = this.ReadCookie(ChartScatterSeriesPicker.COOKIE_Y); + + let xSeries = this.FirstValidColumn(colNameList, [cookieX, ...preferredXList, ...colNameList]); + let ySeries = this.FirstValidColumn(colNameList, [cookieY, ...preferredYList, ...colNameList]); + + // final fallback for degenerate datasets + if (xSeries == undefined) { xSeries = colNameList[0]; } + if (ySeries == undefined) { ySeries = colNameList[Math.min(1, colNameList.length - 1)]; } + + return { xSeries, ySeries }; + } + + ResolveInitialMode() + { + let mode = this.ReadCookie(ChartScatterSeriesPicker.COOKIE_MODE); + return mode == "Histogram" ? "Histogram" : "Scatter"; + } + + NormalizePreferredList(preferred) + { + if (Array.isArray(preferred)) + { + return preferred.filter(v => typeof v == "string" && v.trim() != ""); + } + + if (typeof preferred == "string" && preferred.trim() != "") + { + return [preferred]; + } + + return []; + } + + FirstValidColumn(colNameList, candidateList, disallow) + { + for (const candidate of candidateList) + { + if (candidate && colNameList.includes(candidate) && candidate != disallow) + { + return candidate; + } + } + + return undefined; + } + + ReadCookie(name) + { + let prefix = `${name}=`; + let partList = document.cookie.split(";"); + + for (let part of partList) + { + part = part.trim(); + if (part.startsWith(prefix)) + { + return decodeURIComponent(part.substring(prefix.length)); + } + } + + return undefined; + } + + WriteCookie(name, val) + { + // Use a far-future expiry so preferences effectively do not expire. + let expires = "Fri, 31 Dec 9999 23:59:59 GMT"; + document.cookie = `${name}=${encodeURIComponent(val)}; Expires=${expires}; Path=/; SameSite=Lax`; + } + + WriteCookieDays(name, val, days) + { + let ms = days * 24 * 60 * 60 * 1000; + let expires = new Date(Date.now() + ms).toUTCString(); + document.cookie = `${name}=${encodeURIComponent(val)}; Expires=${expires}; Path=/; SameSite=Lax`; + } + + DeleteCookie(name) + { + document.cookie = `${name}=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; SameSite=Lax`; + } + + #LoadHistogramSeriesSettingsFromCookie() + { + let json = this.ReadCookie(ChartScatterSeriesPicker.COOKIE_HISTOGRAM_SERIES_SETTINGS); + if (!json) + { + this.histogramSeriesSettingsByName = {}; + return; + } + + try + { + let parsed = JSON.parse(json); + this.histogramSeriesSettingsByName = (parsed && typeof parsed == "object") ? parsed : {}; + } + catch + { + this.histogramSeriesSettingsByName = {}; + } + } + + #SaveHistogramSeriesSettingsToCookie() + { + this.WriteCookie(ChartScatterSeriesPicker.COOKIE_HISTOGRAM_SERIES_SETTINGS, JSON.stringify(this.histogramSeriesSettingsByName)); + } + + #GetDefaultHistogramSeriesSettings(colName) + { + return { + bucketSize: this.GetInitialBucketSizeForSeries(this.data.td, colName), + minValue: "", + maxValue: "", + }; + } + + #ApplyHistogramSeriesSettings(colName) + { + let saved = this.histogramSeriesSettingsByName[colName]; + let defaults = this.#GetDefaultHistogramSeriesSettings(colName); + + this.guiState.bucketSize = saved?.bucketSize ?? defaults.bucketSize; + this.guiState.minValue = saved?.minValue ?? defaults.minValue; + this.guiState.maxValue = saved?.maxValue ?? defaults.maxValue; + this.NormalizeBucketSize(); + this.NormalizeRangeBounds(); + this.#PersistCurrentHistogramSeriesSettings(); + } + + #PersistCurrentHistogramSeriesSettings() + { + let colName = this.guiState.xSeries; + if (!colName) + { + return; + } + + this.histogramSeriesSettingsByName[colName] = { + bucketSize: this.#FormatBucketSize(Number(this.guiState.bucketSize)), + minValue: this.guiState.minValue ?? "", + maxValue: this.guiState.maxValue ?? "", + }; + this.#SaveHistogramSeriesSettingsToCookie(); + } + + #ApplyGuiOpenClosePreference() + { + if (!this.gui) + { + return; + } + + let isClosed = this.ReadCookie(ChartScatterSeriesPicker.COOKIE_GUI_CLOSED) == "1"; + if (isClosed) + { + this.gui.close(); + } + else + { + // Default-open behavior when cookie is absent/expired. + this.gui.open(); + } + } + + #PersistGuiOpenClosePreference() + { + if (!this.gui) + { + return; + } + + if (this.gui.closed) + { + // Remember closed state for 14 days, then forget back to default-open. + this.WriteCookieDays(ChartScatterSeriesPicker.COOKIE_GUI_CLOSED, "1", 14); + } + else + { + // Open is default behavior; no need to persist. + this.DeleteCookie(ChartScatterSeriesPicker.COOKIE_GUI_CLOSED); + } + } + + #UpdateGuiOpenCloseLabel() + { + let button = this.guiOpenCloseButton; + if (!button) + { + return; + } + + button.textContent = this.gui.closed ? "Open Configuration" : "Close Configuration"; + } + + #InstallGuiOpenCloseButton() + { + let guiEl = this.gui?.domElement; + let nativeCloseButton = guiEl?.querySelector(".close-button"); + let listEl = guiEl?.querySelector("ul"); + if (!guiEl || !nativeCloseButton || !listEl) + { + return; + } + + nativeCloseButton.style.display = "none"; + + if (!this.guiOpenCloseButton) + { + let button = document.createElement("div"); + button.className = "close-button"; + button.style.position = "relative"; + button.style.bottom = "auto"; + button.style.margin = "0"; + button.style.width = "100%"; + button.style.boxSizing = "border-box"; + button.style.cursor = "pointer"; + button.addEventListener("click", () => { + if (this.gui.closed) + { + this.gui.open(); + } + else + { + this.gui.close(); + } + }); + this.guiOpenCloseButton = button; + } + + if (this.guiOpenCloseButton.parentElement !== guiEl) + { + guiEl.insertBefore(this.guiOpenCloseButton, listEl); + } + } + + #HookGuiOpenClosePersistence() + { + if (!this.gui) + { + return; + } + + let openOriginal = this.gui.open.bind(this.gui); + this.gui.open = () => { + openOriginal(); + this.#PersistGuiOpenClosePreference(); + this.#UpdateGuiOpenCloseLabel(); + return this.gui; + }; + + let closeOriginal = this.gui.close.bind(this.gui); + this.gui.close = () => { + closeOriginal(); + this.#PersistGuiOpenClosePreference(); + this.#UpdateGuiOpenCloseLabel(); + return this.gui; + }; + + this.#UpdateGuiOpenCloseLabel(); + } + GetInitialBucketSizeForSeries(td, colName) + { + let valueListRaw = td.ExtractDataOnly([colName]); + let valueList = []; + let maxDecimals = 0; + + for (const row of valueListRaw) + { + let value = row[0]; + let num = Number(value); + if (!Number.isFinite(num)) + { + continue; + } + + valueList.push(num); + + let text = String(value).trim(); + if (text == "") + { + text = String(num); + } + + let decimals = 0; + if (/[eE]/.test(text)) + { + let fixed = num.toFixed(12).replace(/0+$/, "").replace(/\.$/, ""); + let dot = fixed.indexOf("."); + decimals = dot == -1 ? 0 : (fixed.length - dot - 1); + } + else + { + let dot = text.indexOf("."); + decimals = dot == -1 ? 0 : (text.length - dot - 1); + } + + if (decimals > maxDecimals) + { + maxDecimals = decimals; + } + } + + if (valueList.length == 0) + { + return "1"; + } + + valueList.sort((a, b) => a - b); + + let minValue = valueList[0]; + let maxValue = valueList[valueList.length - 1]; + let range = maxValue - minValue; + let precisionStep = 10 ** (-maxDecimals); + if (!Number.isFinite(precisionStep) || precisionStep <= 0) + { + precisionStep = 1; + } + + if (range <= 0) + { + return this.#FormatBucketSize(precisionStep); + } + + let bucketWidth = this.#GetFreedmanDiaconisWidth(valueList); + if (!Number.isFinite(bucketWidth) || bucketWidth <= 0) + { + let sqrtBucketCount = Math.max(1, Math.ceil(Math.sqrt(valueList.length))); + bucketWidth = range / sqrtBucketCount; + } + + let minBucketCount = 8; + let maxBucketCount = 24; + let minWidth = range / maxBucketCount; + let maxWidth = range / minBucketCount; + if (Number.isFinite(minWidth) && minWidth > 0) + { + bucketWidth = Math.max(bucketWidth, minWidth); + } + if (Number.isFinite(maxWidth) && maxWidth > 0) + { + bucketWidth = Math.min(bucketWidth, maxWidth); + } + + bucketWidth = this.#SnapUpToNiceBucketWidth(bucketWidth, precisionStep); + return this.#FormatBucketSize(bucketWidth); + } + + #GetFreedmanDiaconisWidth(sortedValueList) + { + let n = sortedValueList.length; + if (n < 2) + { + return NaN; + } + + let q1 = this.#GetQuantile(sortedValueList, 0.25); + let q3 = this.#GetQuantile(sortedValueList, 0.75); + let iqr = q3 - q1; + if (!Number.isFinite(iqr) || iqr <= 0) + { + return NaN; + } + + let width = (2 * iqr) / Math.cbrt(n); + return width > 0 ? width : NaN; + } + + #GetQuantile(sortedValueList, p) + { + let count = sortedValueList.length; + if (count == 0) + { + return NaN; + } + + if (count == 1) + { + return sortedValueList[0]; + } + + let idx = (count - 1) * p; + let lowerIdx = Math.floor(idx); + let upperIdx = Math.ceil(idx); + let lower = sortedValueList[lowerIdx]; + let upper = sortedValueList[upperIdx]; + let frac = idx - lowerIdx; + return lower + ((upper - lower) * frac); + } + + #SnapUpToNiceBucketWidth(bucketWidth, baseStep) + { + if (!Number.isFinite(bucketWidth) || bucketWidth <= 0) + { + return baseStep > 0 ? baseStep : 1; + } + + let unit = (Number.isFinite(baseStep) && baseStep > 0) ? baseStep : 1; + let ratio = Math.max(1, bucketWidth / unit); + let exp = Math.floor(Math.log10(ratio)); + let scaled = ratio / (10 ** exp); + let niceMantissaList = [1, 2, 2.5, 5, 10]; + let mantissa = niceMantissaList[niceMantissaList.length - 1]; + + for (const candidate of niceMantissaList) + { + if (scaled <= candidate) + { + mantissa = candidate; + break; + } + } + + return unit * mantissa * (10 ** exp); + } + + #FormatBucketSize(bucketSize) + { + if (!Number.isFinite(bucketSize) || bucketSize <= 0) + { + return "1"; + } + + if (Math.abs(bucketSize) >= 1) + { + return String(Number(bucketSize.toFixed(12))); + } + + let decimals = Math.min(12, Math.max(0, Math.ceil(-Math.log10(bucketSize)) + 2)); + return String(Number(bucketSize.toFixed(decimals))); + } + + NormalizeBucketSize() + { + let bucketSize = Number(this.guiState.bucketSize); + if (!Number.isFinite(bucketSize) || bucketSize <= 0) + { + bucketSize = Number(this.GetInitialBucketSizeForSeries(this.data.td, this.guiState.xSeries)); + } + + this.guiState.bucketSize = this.#FormatBucketSize(bucketSize); + } + + NormalizeRangeBounds() + { + let normalize = (value) => { + if (value == undefined || value == null) + { + return ""; + } + + let text = String(value).trim(); + if (text == "") + { + return ""; + } + + let num = Number(text); + if (!Number.isFinite(num)) + { + return ""; + } + + return String(num); + }; + + this.guiState.minValue = normalize(this.guiState.minValue); + this.guiState.maxValue = normalize(this.guiState.maxValue); + + if (this.guiState.minValue !== "" && this.guiState.maxValue !== "") + { + let minValue = Number(this.guiState.minValue); + let maxValue = Number(this.guiState.maxValue); + if (minValue > maxValue) + { + this.guiState.maxValue = this.guiState.minValue; + } + } + } + + GetHistogramData() + { + if (!this.data || !this.data.td) + { + return null; + } + + let td = this.data.td; + let xSeries = this.guiState.xSeries; + let bucketSize = Number(this.guiState.bucketSize); + if (!Number.isFinite(bucketSize) || bucketSize <= 0) + { + return null; + } + + this.NormalizeRangeBounds(); + let minLimit = this.guiState.minValue === "" ? null : Number(this.guiState.minValue); + let maxLimit = this.guiState.maxValue === "" ? null : Number(this.guiState.maxValue); + + let valueList = []; + for (const row of td.ExtractDataOnly([xSeries])) + { + let value = Number(row[0]); + if (Number.isFinite(value)) + { + if (minLimit !== null && value < minLimit) + { + continue; + } + if (maxLimit !== null && value > maxLimit) + { + continue; + } + valueList.push(value); + } + } + + if (valueList.length == 0) + { + return { + bucketLabelList: [], + bucketCountList: [], + xAxisName: xSeries, + yAxisName: "Count", + }; + } + + let minValue = Math.min(...valueList); + let maxValue = Math.max(...valueList); + let start = Math.floor(minValue / bucketSize) * bucketSize; + let bucketCount = Math.max(1, Math.floor((maxValue - start) / bucketSize) + 1); + let bucketCountList = new Array(bucketCount).fill(0); + + for (const value of valueList) + { + let idx = Math.floor((value - start) / bucketSize); + idx = Math.max(0, Math.min(bucketCount - 1, idx)); + bucketCountList[idx] += 1; + } + + let decimals = 0; + let bucketText = String(this.guiState.bucketSize); + if (bucketText.includes(".")) + { + decimals = bucketText.length - bucketText.indexOf(".") - 1; + } + + let formatBucketEdge = (value) => { + let rounded = Number(value.toFixed(Math.min(12, Math.max(0, decimals)))); + return rounded.toLocaleString("en-US", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); + }; + + let bucketLabelList = []; + for (let i = 0; i < bucketCount; ++i) + { + let low = start + (i * bucketSize); + let high = low + bucketSize; + bucketLabelList.push(`${formatBucketEdge(low)} to < ${formatBucketEdge(high)}`); + } + + return { + bucketLabelList, + bucketCountList, + xAxisName: xSeries, + yAxisName: "Count", + grid: { + top: 30, + left: 48, + right: 18, + bottom: 82, + }, + xAxisNameGap: 58, + xAxisLabelRotate: 30, + xAxisLabelMargin: 10, + yAxisNameGap: 12, + }; + } + + RenderHistogram() + { + if (!this.data || !this.data.td) + { + return; + } + + if (this.gui) + { + this.#UpdateGuiLayout(this.data.colNameList || []); + } + + this.NormalizeBucketSize(); + let hist = this.GetHistogramData(); + if (!hist) + { + return; + } + + this.chart.setOption({ + grid: hist.grid, + xAxis: { + type: "category", + data: hist.bucketLabelList, + name: hist.xAxisName, + nameLocation: "middle", + nameGap: hist.xAxisNameGap, + axisLabel: { + interval: 1, + rotate: hist.xAxisLabelRotate, + fontSize: 10, + margin: hist.xAxisLabelMargin, + showMaxLabel: true, + hideOverlap: false, + }, + }, + yAxis: { + type: "value", + name: hist.yAxisName, + min: 0, + minInterval: 1, + nameGap: hist.yAxisNameGap, + axisLabel: { + formatter: EChartsUtils.RoundCommas, + }, + }, + tooltip: { + trigger: "axis", + }, + series: [ + { + type: "bar", + data: hist.bucketCountList, + barMaxWidth: 24, + }, + ], + animation: false, + }, true); + } +} diff --git a/js/CodecExpandedBasicTelemetry.js b/js/CodecExpandedBasicTelemetry.js new file mode 100644 index 0000000..81a5c3d --- /dev/null +++ b/js/CodecExpandedBasicTelemetry.js @@ -0,0 +1,130 @@ +/* +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 { WsprCodecMaker } from '/pro/codec/WsprCodec.js'; +import { WSPREncoded } from '/js/WSPREncoded.js'; + + +export class CodecExpandedBasicTelemetry +extends WsprCodecMaker +{ + static HDR_TYPE = 2; + static HDR_TELEMETRY_TYPE = 0; + static HDR_RESERVED = 0; + static REFERENCE_GRID_WIDTH_DEG = 2; + static REFERENCE_GRID_HEIGHT_DEG = 1; + + constructor() + { + super(); + + this.SetCodecDefFragment("ExpandedBasicTelemetry", ` + { "name": "Temp", "unit": "F", "valueSegmentList": [[-60, 5, -30], [-30, 3, 30], [30, 8, 70]] }, + { "name": "Voltage", "unit": "V", "valueSegmentList": [[1.8, 0.300, 3.0], [3.0, 0.0625, 5.0], [5.0, 0.200, 6.0], [6.0, 0.500, 7.0]] }, + { "name": "GpsValid", "unit": "Bool", "lowValue": 0, "highValue": 1, "stepSize": 1 }, + { "name": "Latitude", "unit": "Idx", "lowValue": 0, "highValue": 15, "stepSize": 1 }, + { "name": "Longitude", "unit": "Idx", "lowValue": 0, "highValue": 35, "stepSize": 1 }, + { "name": "Altitude", "unit": "Ft", "valueSegmentList": [[0, 75, 3300], [3300, 300, 33000], [33000, 75, 45000], [45000, 500, 60000], [60000, 1500, 120000]] }, + `); + } + + GetHdrTypeValue() + { + return CodecExpandedBasicTelemetry.HDR_TYPE; + } + + GetHdrTelemetryTypeValue() + { + return CodecExpandedBasicTelemetry.HDR_TELEMETRY_TYPE; + } + + GetHdrReservedValue() + { + return CodecExpandedBasicTelemetry.HDR_RESERVED; + } + + GetReferencedGridWidthDeg() + { + return CodecExpandedBasicTelemetry.REFERENCE_GRID_WIDTH_DEG; + } + + GetReferencedGridHeightDeg() + { + return CodecExpandedBasicTelemetry.REFERENCE_GRID_HEIGHT_DEG; + } + + GetLatitudeBinCount() + { + let codec = this.GetCodecInstance(); + + return codec.GetLatitudeIdxHighValue() - codec.GetLatitudeIdxLowValue() + 1; + } + + GetLongitudeBinCount() + { + let codec = this.GetCodecInstance(); + + return codec.GetLongitudeIdxHighValue() - codec.GetLongitudeIdxLowValue() + 1; + } + + GetReferenceGridSouthwestCorner(grid4) + { + return WSPREncoded.DecodeMaidenheadToDeg(grid4.substring(0, 4), { snap: "southwest" }); + } + + IsCodecExpandedBasicTelemetry(codec) + { + return codec?.GetHdrTypeEnum?.() == this.GetHdrTypeValue(); + } + + EncodeLocationToFieldValues(lat, lng) + { + let grid4 = WSPREncoded.GetReferenceGrid4(lat, lng); + let [baseLat, baseLng] = this.GetReferenceGridSouthwestCorner(grid4); + + let latDegDiff = Number(lat) - baseLat; + let lngDegDiff = Number(lng) - baseLng; + let latIsInBounds = latDegDiff >= 0 && latDegDiff < this.GetReferencedGridHeightDeg(); + let lngIsInBounds = lngDegDiff >= 0 && lngDegDiff < this.GetReferencedGridWidthDeg(); + + if (!latIsInBounds || !lngIsInBounds) + { + throw new RangeError(`Location ${lat}, ${lng} is outside reference grid ${grid4}.`); + } + + let latitudeIdx = Math.floor(latDegDiff * this.GetLatitudeBinCount() / this.GetReferencedGridHeightDeg()); + let longitudeIdx = Math.floor(lngDegDiff * this.GetLongitudeBinCount() / this.GetReferencedGridWidthDeg()); + + return { + grid4, + latitudeIdx, + longitudeIdx, + }; + } + + DecodeFieldValuesToLocation(grid4, latitudeIdx, longitudeIdx) + { + latitudeIdx = Number(latitudeIdx); + longitudeIdx = Number(longitudeIdx); + + if (isNaN(latitudeIdx) || isNaN(longitudeIdx)) + { + return null; + } + + let [baseLat, baseLng] = this.GetReferenceGridSouthwestCorner(grid4); + + let lat = baseLat + (latitudeIdx + 0.5) * this.GetReferencedGridHeightDeg() / this.GetLatitudeBinCount(); + let lng = baseLng + (longitudeIdx + 0.5) * this.GetReferencedGridWidthDeg() / this.GetLongitudeBinCount(); + + return { + lat, + lng, + }; + } +} diff --git a/js/CodecHeartbeat.js b/js/CodecHeartbeat.js new file mode 100644 index 0000000..5dfbc6e --- /dev/null +++ b/js/CodecHeartbeat.js @@ -0,0 +1,95 @@ +/* +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 { WsprCodecMaker } from '/pro/codec/WsprCodec.js'; +import { WSPR } from '/js/WSPR.js'; + + +export class CodecHeartbeat +extends WsprCodecMaker +{ + static HDR_TYPE = 1; + static HDR_TELEMETRY_TYPE = 0; + static HDR_RESERVED = 0; + + static GPS_LOCK_TYPE_NO_LOCK = 0; + static GPS_LOCK_TYPE_TIME_LOCK = 1; + static GPS_LOCK_TYPE_LOCATION_LOCK = 2; + static TX_FREQ_LOW_OFFSET_HZ = 1400; + + constructor() + { + super(); + + this.SetCodecDefFragment("Heartbeat", ` + { "name": "TxFreqHz", "unit": "Idx", "lowValue": 0, "highValue": 200, "stepSize": 1 }, + { "name": "Uptime", "unit": "Minutes", "lowValue": 0, "highValue": 1440, "stepSize": 10 }, + { "name": "GpsLockType", "unit": "Enum", "lowValue": 0, "highValue": 2, "stepSize": 1 }, + { "name": "GpsTryLock", "unit": "Seconds", "lowValue": 0, "highValue": 1200, "stepSize": 5 }, + { "name": "GpsSatsInView", "unit": "Count", "lowValue": 0, "highValue": 50, "stepSize": 2 }, + `); + } + + GetHdrTypeValue() + { + return CodecHeartbeat.HDR_TYPE; + } + + GetHdrTelemetryTypeValue() + { + return CodecHeartbeat.HDR_TELEMETRY_TYPE; + } + + GetHdrReservedValue() + { + return CodecHeartbeat.HDR_RESERVED; + } + + GetGpsLockTypeNoLockValue() + { + return CodecHeartbeat.GPS_LOCK_TYPE_NO_LOCK; + } + + GetGpsLockTypeTimeLockValue() + { + return CodecHeartbeat.GPS_LOCK_TYPE_TIME_LOCK; + } + + GetGpsLockTypeLocationLockValue() + { + return CodecHeartbeat.GPS_LOCK_TYPE_LOCATION_LOCK; + } + + GetTxFreqLowOffsetHz() + { + return CodecHeartbeat.TX_FREQ_LOW_OFFSET_HZ; + } + + GetReferenceTxFreqHzFromBand(band) + { + let dialFreqHz = WSPR.GetDialFreqFromBandStr(band); + + return dialFreqHz + this.GetTxFreqLowOffsetHz(); + } + + DecodeTxFreqHzFromBand(band, txFreqHzIdx) + { + txFreqHzIdx = Number(txFreqHzIdx); + if (isNaN(txFreqHzIdx)) + { + return null; + } + + return this.GetReferenceTxFreqHzFromBand(band) + txFreqHzIdx; + } + + IsCodecHeartbeat(codec) + { + return codec?.GetHdrTypeEnum?.() == this.GetHdrTypeValue(); + } +} diff --git a/js/CodecHighResLocation.js b/js/CodecHighResLocation.js new file mode 100644 index 0000000..c5a030c --- /dev/null +++ b/js/CodecHighResLocation.js @@ -0,0 +1,153 @@ +/* +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 { WsprCodecMaker } from '/pro/codec/WsprCodec.js'; +import { WSPREncoded } from '/js/WSPREncoded.js'; + + +export class CodecHighResLocation +extends WsprCodecMaker +{ + static HDR_TYPE = 3; + static HDR_TELEMETRY_TYPE = 0; + static HDR_RESERVED = 0; + static REFERENCE_RESERVED = 0; + static REFERENCE_ESTABLISHED_GRID4 = 1; + static REFERENCE_GRID_WIDTH_DEG = 2; + static REFERENCE_GRID_HEIGHT_DEG = 1; + + constructor() + { + super(); + + this.SetCodecDefFragment("HighResLocation", ` + { "name": "Reference", "unit": "Enum", "lowValue": 0, "highValue": 1, "stepSize": 1 }, + { "name": "Latitude", "unit": "Idx", "lowValue": 0, "highValue": 12352, "stepSize": 1 }, + { "name": "Longitude", "unit": "Idx", "lowValue": 0, "highValue": 24617, "stepSize": 1 }, + `); + } + + GetReferenceReservedValue() + { + return CodecHighResLocation.REFERENCE_RESERVED; + } + + GetHdrTypeValue() + { + return CodecHighResLocation.HDR_TYPE; + } + + GetHdrTelemetryTypeValue() + { + return CodecHighResLocation.HDR_TELEMETRY_TYPE; + } + + GetHdrReservedValue() + { + return CodecHighResLocation.HDR_RESERVED; + } + + GetReferenceEstablishedGrid4Value() + { + return CodecHighResLocation.REFERENCE_ESTABLISHED_GRID4; + } + + GetReferencedGridWidthDeg() + { + return CodecHighResLocation.REFERENCE_GRID_WIDTH_DEG; + } + + GetReferencedGridHeightDeg() + { + return CodecHighResLocation.REFERENCE_GRID_HEIGHT_DEG; + } + + GetLatitudeBinCount() + { + let codec = this.GetCodecInstance(); + + return codec.GetLatitudeIdxHighValue() - codec.GetLatitudeIdxLowValue() + 1; + } + + GetLongitudeBinCount() + { + let codec = this.GetCodecInstance(); + + return codec.GetLongitudeIdxHighValue() - codec.GetLongitudeIdxLowValue() + 1; + } + + GetReferenceGridSouthwestCorner(grid4) + { + return WSPREncoded.DecodeMaidenheadToDeg(grid4.substring(0, 4), { snap: "southwest" }); + } + + IsCodecHighResLocation(codec) + { + return codec?.GetHdrTypeEnum?.() == this.GetHdrTypeValue(); + } + + IsReferenceEstablishedGrid4(referenceEnum) + { + return Number(referenceEnum) == this.GetReferenceEstablishedGrid4Value(); + } + + EncodeLocationToFieldValues(lat, lng) + { + let grid4 = WSPREncoded.GetReferenceGrid4(lat, lng); + let [baseLat, baseLng] = this.GetReferenceGridSouthwestCorner(grid4); + + let latDegDiff = Number(lat) - baseLat; + let lngDegDiff = Number(lng) - baseLng; + let latIsInBounds = latDegDiff >= 0 && latDegDiff < this.GetReferencedGridHeightDeg(); + let lngIsInBounds = lngDegDiff >= 0 && lngDegDiff < this.GetReferencedGridWidthDeg(); + + if (!latIsInBounds || !lngIsInBounds) + { + throw new RangeError( + `Location ${lat}, ${lng} is outside reference grid ${grid4}.` + ); + } + + let latitudeIdx = Math.floor(latDegDiff * this.GetLatitudeBinCount() / this.GetReferencedGridHeightDeg()); + let longitudeIdx = Math.floor(lngDegDiff * this.GetLongitudeBinCount() / this.GetReferencedGridWidthDeg()); + + return { + grid4, + referenceEnum: this.GetReferenceEstablishedGrid4Value(), + latitudeIdx, + longitudeIdx, + }; + } + + DecodeFieldValuesToLocation(grid4, referenceEnum, latitudeIdx, longitudeIdx) + { + if (!this.IsReferenceEstablishedGrid4(referenceEnum)) + { + return null; + } + + latitudeIdx = Number(latitudeIdx); + longitudeIdx = Number(longitudeIdx); + + if (isNaN(latitudeIdx) || isNaN(longitudeIdx)) + { + return null; + } + + let [baseLat, baseLng] = this.GetReferenceGridSouthwestCorner(grid4); + + let lat = baseLat + (latitudeIdx + 0.5) * this.GetReferencedGridHeightDeg() / this.GetLatitudeBinCount(); + let lng = baseLng + (longitudeIdx + 0.5) * this.GetReferencedGridWidthDeg() / this.GetLongitudeBinCount(); + + return { + lat, + lng, + }; + } + +} diff --git a/js/DomWidgets.js b/js/DomWidgets.js new file mode 100644 index 0000000..d6daf1a --- /dev/null +++ b/js/DomWidgets.js @@ -0,0 +1,664 @@ +/* +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.) +*/ + +function GetViewableWidthAccountingForScrollbar() +{ + // Check if a vertical scrollbar is present + const isVerticalScrollbarPresent = document.documentElement.scrollHeight > window.innerHeight; + + // If no vertical scrollbar, return the innerWidth as is + if (!isVerticalScrollbarPresent) { + return window.innerWidth; + } + + // Create a temporary element to measure the scrollbar width + const div = document.createElement('div'); + div.style.visibility = 'hidden'; // Make sure it's not visible + div.style.position = 'absolute'; + div.style.width = '100px'; // Set a fixed width for the element + div.style.overflow = 'scroll'; // Enable scroll to ensure a scrollbar appears + document.body.appendChild(div); + + // Calculate the scrollbar width + const scrollbarWidth = div.offsetWidth - div.clientWidth; + + // Clean up the temporary div + document.body.removeChild(div); + + // Return the viewport width excluding the scrollbar + return window.innerWidth - scrollbarWidth; +} + +function GetViewableHeightAccountingForScrollbar() +{ + // Check if a horizontal scrollbar is present + const isHorizontalScrollbarPresent = document.documentElement.scrollWidth > window.innerWidth; + + // If no horizontal scrollbar, return the innerHeight as is + if (!isHorizontalScrollbarPresent) { + return window.innerHeight; + } + + // Create a temporary element to measure the scrollbar height + const div = document.createElement('div'); + div.style.visibility = 'hidden'; // Make sure it's not visible + div.style.position = 'absolute'; + div.style.height = '100px'; // Set a fixed height for the element + div.style.overflow = 'scroll'; // Enable scroll to ensure a scrollbar appears + document.body.appendChild(div); + + // Calculate the scrollbar height + const scrollbarHeight = div.offsetHeight - div.clientHeight; + + // Clean up the temporary div + document.body.removeChild(div); + + // Return the viewport height excluding the scrollbar + return window.innerHeight - scrollbarHeight; +} + + + + + + + +class ZIndexHelper +{ + static BASE_Z_INDEX = 1000; + + constructor() + { + this.objDataList = []; + } + + // objects register to have a given property set to the zIndex to make them + // the top-most at this time, and later in the future + RegisterForTop(obj, prop) + { + this.objDataList.push({ + obj, + prop, + }); + + this.#AnnounceAll(); + + return this.objDataList.length; + } + + // request immediate top level + RequestTop(obj) + { + // find its current location + let idxFound = -1; + for (let zIndex = 0; zIndex < this.objDataList.length; ++zIndex) + { + let objData = this.objDataList[zIndex]; + + if (objData.obj == obj) + { + idxFound = zIndex; + } + } + + if (idxFound != -1) + { + // hold temporarily + let objData = this.objDataList[idxFound]; + + // delete its location, effectively compacting list + this.objDataList.splice(idxFound, 1); + + // re-insert + this.objDataList.push(objData); + + // announce re-index + this.#AnnounceAll(); + } + } + + #AnnounceAll() + { + for (let zIndex = 0; zIndex < this.objDataList.length; ++zIndex) + { + let objData = this.objDataList[zIndex]; + + objData.obj[objData.prop] = ZIndexHelper.BASE_Z_INDEX + zIndex; + } + } +} + + + +export class DialogBox +{ + static #zIndexHelper = new ZIndexHelper(); + static #instanceList = []; + static #escapeHandlerSet = false; + + constructor() + { + this.isDragging = false; + this.offsetX = 0; + this.offsetY = 0; + + this.ui = this.#MakeUI(); + + DialogBox.#instanceList.push(this); + DialogBox.#EnsureEscapeHandler(); + } + + GetUI() + { + return this.ui; + } + + SetTitleBar(title) + { + this.titleBar.innerHTML = title; + } + + GetContentContainer() + { + return this.frameBody; + } + + ToggleShowHide() + { + if (this.floatingWindow.style.display === 'none') + { + this.Show(); + } + else + { + this.Hide(); + } + } + + Show() + { + const STEP_SIZE_PIXELS = 50; + + let zIndex = DialogBox.#zIndexHelper.RegisterForTop(this.floatingWindow.style, "zIndex"); + + if (this.floatingWindow.style.top == "50px" && + this.floatingWindow.style.left == "50px") + { + this.floatingWindow.style.top = `${STEP_SIZE_PIXELS * zIndex}px`; + this.floatingWindow.style.left = `${STEP_SIZE_PIXELS * zIndex}px`; + } + + this.floatingWindow.style.display = 'flex'; +} + + Hide() + { + DialogBox.#zIndexHelper.RequestTop(this.floatingWindow.style); + + this.floatingWindow.style.display = 'none'; + } + + static #EnsureEscapeHandler() + { + if (DialogBox.#escapeHandlerSet) + { + return; + } + + DialogBox.#escapeHandlerSet = true; + + document.addEventListener('keydown', (e) => { + if (e.key !== "Escape") + { + return; + } + + let topMost = null; + let topZ = Number.NEGATIVE_INFINITY; + for (const dlg of DialogBox.#instanceList) + { + if (!dlg || !dlg.floatingWindow) + { + continue; + } + + if (dlg.floatingWindow.style.display === 'none') + { + continue; + } + + let z = parseInt(dlg.floatingWindow.style.zIndex || "0"); + if (isNaN(z)) + { + z = 0; + } + + if (z >= topZ) + { + topZ = z; + topMost = dlg; + } + } + + if (topMost) + { + topMost.Hide(); + e.preventDefault(); + e.stopPropagation(); + } + }); + } + + #MakeFloatingWindowFrame() + { + this.floatingWindow = document.createElement('div'); + + this.floatingWindow.style.boxSizing = "border-box"; + + this.floatingWindow.style.position = 'fixed'; + this.floatingWindow.style.top = '50px'; + this.floatingWindow.style.left = '50px'; + + this.floatingWindow.style.backgroundColor = '#f0f0f0'; + + this.floatingWindow.style.border = '1px solid black'; + this.floatingWindow.style.borderRadius = '5px'; + + this.floatingWindow.style.boxShadow = '2px 2px 8px black'; + + this.floatingWindow.style.padding = '0px'; + + this.floatingWindow.style.display = 'none'; // Initially hidden + this.floatingWindow.style.zIndex = 1; + + this.floatingWindow.style.flexDirection = "column"; + + + return this.floatingWindow; + } + + #MakeTopRow() + { + // create top row + this.topRow = document.createElement('div'); + this.topRow.style.boxSizing = "border-box"; + + this.topRow.style.borderBottom = "1px solid black"; + this.topRow.style.borderTopRightRadius = "5px"; + this.topRow.style.borderTopLeftRadius = "5px"; + this.topRow.style.display = "flex"; + this.topRow.style.backgroundColor = "#ff323254"; + + // top row - title bar + this.titleBar = document.createElement('div'); + this.titleBar.style.boxSizing = "border-box"; + + this.titleBar.style.flexGrow = "1"; + this.titleBar.style.borderRight = "1px solid black"; + this.titleBar.style.borderTopLeftRadius = "5px"; + + this.titleBar.style.padding = "3px"; + this.titleBar.style.backgroundColor = 'rgb(255, 255, 200)'; + this.titleBar.style.cursor = 'move'; // Indicate draggable behavior + this.titleBar.innerHTML = "Dialog Box"; + this.topRow.appendChild(this.titleBar); + + // top row - close button + const closeButton = document.createElement('button'); + closeButton.textContent = 'X'; + // closeButton.style.cursor = 'pointer'; + closeButton.style.border = 'none'; + closeButton.style.backgroundColor = 'rgba(0,0,0,0)'; // transparent + + this.topRow.appendChild(closeButton); + + // Close button event handling + closeButton.addEventListener('click', () => { + this.Hide(); + }); + + return this.topRow; + } + + #MakeBody() + { + let dom = document.createElement('div'); + dom.style.boxSizing = "border-box"; + dom.style.padding = "3px"; + dom.style.width = "100%"; + dom.style.flexGrow = "1"; + dom.style.backgroundColor = "rgb(210, 210, 210)"; + + // only show scrollbars if necessary + // (eg someone manually resizes dialog smaller than content minimum size) + dom.style.overflowX = "auto"; + dom.style.overflowY = "auto"; + dom.style.scrollbarGutter = "stable"; + + // don't scroll the page, just the div + let ScrollJustThis = dom => { + dom.addEventListener('wheel', (e) => { + const hasVerticalScrollbar = dom.scrollHeight > dom.clientHeight; + + if (hasVerticalScrollbar) + { + e.stopPropagation(); + } + else + { + e.preventDefault(); + } + }); + }; + + // ScrollJustThis(dom) + + return dom; + } + + #EnableDrag() + { + this.floatingWindow.addEventListener('mousedown', (e) => { + e.stopPropagation(); + + DialogBox.#zIndexHelper.RequestTop(this.floatingWindow.style); + }); + + this.titleBar.addEventListener('mousedown', (e) => { + e.stopPropagation(); + + DialogBox.#zIndexHelper.RequestTop(this.floatingWindow.style); + + this.isDragging = true; + this.offsetX = e.clientX - this.floatingWindow.getBoundingClientRect().left; + this.offsetY = e.clientY - this.floatingWindow.getBoundingClientRect().top; + document.body.style.userSelect = 'none'; // Prevent text selection during drag + }); + + // Drag the window + document.addEventListener('mousemove', (e) => { + if (this.isDragging) { + // determine viewable area + let viewableWidth = GetViewableWidthAccountingForScrollbar(); + let viewableHeight = GetViewableHeightAccountingForScrollbar(); + + // prevent mouse from dragging popup outside the viewable + // area on the left, right, and bottom. + let cursorX = e.clientX; + if (cursorX < 0) { cursorX = 0; } + if (cursorX > viewableWidth) { cursorX = viewableWidth; } + let cursorY = e.clientY; + if (cursorY > viewableHeight) { cursorY = viewableHeight; } + + // don't let the dialog go above the window at all + let top = cursorY - this.offsetY; + let left = cursorX - this.offsetX; + if (top < 0) { top = 0; } + + // apply + this.floatingWindow.style.top = `${top}px`; + this.floatingWindow.style.left = `${left}px`; + } + }); + + // Stop dragging + document.addEventListener('mouseup', () => { + if (this.isDragging) { + this.isDragging = false; + document.body.style.userSelect = ''; // Re-enable text selection + } + }); + } + + #MakeUI() + { + let frame = this.#MakeFloatingWindowFrame(); + let frameTopRow = this.#MakeTopRow(); + this.frameBody = this.#MakeBody(); + + this.frameBody.marginTop = "2px"; + + frame.appendChild(frameTopRow); + frame.appendChild(this.frameBody); + + // don't let the page scroll when you hover the popup + // (scrollable content section handled separately) + // frame.addEventListener('wheel', (e) => { + // e.preventDefault(); + // }); + + this.#EnableDrag(); + + return this.floatingWindow; + } +} + + + +export class CollapsableTitleBox +{ + constructor() + { + this.ui = this.#MakeUI(); + this.#SetUpEvents(); + } + + GetUI() + { + return this.ui; + } + + SetTitle(title) + { + this.titleBar.innerHTML = title; + } + + GetContentContainer() + { + return this.box; + } + + SetMinWidth(minWidth) + { + this.ui.style.minWidth = minWidth; + } + + ToggleShowHide() + { + if (this.box.style.display === 'none') + { + this.Show(); + } + else + { + this.Hide(); + } + } + + Show() + { + this.box.style.display = 'flex'; + } + + Hide() + { + this.box.style.display = 'none'; + } + + #SetUpEvents() + { + this.titleBar.addEventListener('click', () => { + this.ToggleShowHide(); + }); + } + + #MakeUI() + { + // entire structure + this.ui = document.createElement('div'); + this.ui.style.boxSizing = "border-box"; + this.ui.style.backgroundColor = "white"; + this.ui.style.border = "1px solid grey"; + + // user reads this, click to hide/unhide + this.titleBar = document.createElement('div'); + this.titleBar.style.boxSizing = "border-box"; + this.titleBar.style.padding = "3px"; + this.titleBar.style.backgroundColor = "rgb(240, 240, 240)"; + // this.titleBar.style.backgroundColor = "rgb(200, 200, 255)"; + this.titleBar.style.userSelect = "none"; + this.titleBar.style.cursor = "pointer"; + this.titleBar.innerHTML = "Title Bar"; + + // user content goes here + this.box = document.createElement('div'); + this.box.style.boxSizing = "border-box"; + this.box.style.padding = "5px"; + this.box.style.boxShadow = "1px 1px 5px #555 inset"; + this.box.style.overflowX = "auto"; + this.box.style.overflowY = "auto"; + this.box.style.display = 'none'; // initially hidden + + // pack + this.ui.appendChild(this.titleBar); + this.ui.appendChild(this.box); + + return this.ui; + } +} + + +export class RadioCheckbox +{ + constructor(name) + { + this.name = name; + this.ui = this.#MakeUI(); + + this.inputList = []; + + this.fnOnChange = (val) => {}; + } + + AddOption(labelText, value, checked) + { + // create input + let input = document.createElement('input'); + input.type = "radio"; + input.name = this.name; + input.value = value; + if (checked) + { + input.checked = true; + } + this.inputList.push(input); + + // set up label + let label = document.createElement('label'); + label.appendChild(input); + label.appendChild(document.createTextNode(` ${labelText}`)); + + // add to container + if (this.inputList.length != 1) + { + this.ui.appendChild(document.createTextNode(' ')); + } + this.ui.appendChild(label); + + // set up events + input.addEventListener('change', (e) => { + this.fnOnChange(e.target.value); + }); + } + + SetOnChangeCallback(fn) + { + this.fnOnChange = fn; + } + + Trigger() + { + for (let input of this.inputList) + { + if (input.checked) + { + this.fnOnChange(input.value); + break; + } + } + } + + GetUI() + { + return this.ui; + } + + #MakeUI() + { + let ui = document.createElement('span'); + + return ui; + } +} + + +// write through and read-through cache stored persistently +export class RadioCheckboxPersistent +extends RadioCheckbox +{ + constructor(name) + { + super(name); + + this.val = null; + + // cache currently-stored value + if (localStorage.getItem(this.name) != null) + { + this.val = localStorage.getItem(this.name); + } + } + + // add option except checked is just a suggestion. + // if no prior value set, let suggestion take effect. + // if prior value set, prior value rules. + AddOption(labelText, value, checkedSuggestion) + { + let checked = checkedSuggestion; + + if (this.val == null) + { + // let it happen + } + else + { + checked = this.val == value; + } + + super.AddOption(labelText, value, checked); + + // cache and write through + if (checked) + { + this.val = value; + localStorage.setItem(this.name, this.val); + } + } + + SetOnChangeCallback(fn) + { + super.SetOnChangeCallback((val) => { + // capture the new value before passing back + this.val = val; + localStorage.setItem(this.name, this.val); + + // callback + fn(val); + }); + } +} diff --git a/js/GreatCircle.js b/js/GreatCircle.js new file mode 100644 index 0000000..65f652e --- /dev/null +++ b/js/GreatCircle.js @@ -0,0 +1,64 @@ +/* +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.) +*/ + +// Adapted from https://github.com/mwgg/GreatCircle + +export let GreatCircle = { + + validateRadius: function(unit) { + let r = {'M': 6371009, 'KM': 6371.009, 'MI': 3958.761, 'NM': 3440.070, 'YD': 6967420, 'FT': 20902260}; + if ( unit in r ) return r[unit]; + else return unit; + }, + + distance: function(lat1, lon1, lat2, lon2, unit) { + if ( unit === undefined ) unit = 'KM'; + let r = this.validateRadius(unit); + lat1 *= Math.PI / 180; + lon1 *= Math.PI / 180; + lat2 *= Math.PI / 180; + lon2 *= Math.PI / 180; + let lonDelta = lon2 - lon1; + let a = Math.pow(Math.cos(lat2) * Math.sin(lonDelta) , 2) + Math.pow(Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(lonDelta) , 2); + let b = Math.sin(lat1) * Math.sin(lat2) + Math.cos(lat1) * Math.cos(lat2) * Math.cos(lonDelta); + let angle = Math.atan2(Math.sqrt(a) , b); + + return angle * r; + }, + + bearing: function(lat1, lon1, lat2, lon2) { + lat1 *= Math.PI / 180; + lon1 *= Math.PI / 180; + lat2 *= Math.PI / 180; + lon2 *= Math.PI / 180; + let lonDelta = lon2 - lon1; + let y = Math.sin(lonDelta) * Math.cos(lat2); + let x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(lonDelta); + let brng = Math.atan2(y, x); + brng = brng * (180 / Math.PI); + + if ( brng < 0 ) { brng += 360; } + + return brng; + }, + + destination: function(lat1, lon1, brng, dt, unit) { + if ( unit === undefined ) unit = 'KM'; + let r = this.validateRadius(unit); + lat1 *= Math.PI / 180; + lon1 *= Math.PI / 180; + let lat3 = Math.asin(Math.sin(lat1) * Math.cos(dt / r) + Math.cos(lat1) * Math.sin(dt / r) * Math.cos( brng * Math.PI / 180 )); + let lon3 = lon1 + Math.atan2(Math.sin( brng * Math.PI / 180 ) * Math.sin(dt / r) * Math.cos(lat1) , Math.cos(dt / r) - Math.sin(lat1) * Math.sin(lat3)); + + return { + 'LAT': lat3 * 180 / Math.PI, + 'LON': lon3 * 180 / Math.PI + }; + } + +}; diff --git a/js/MsgDefinitionInputUiController.js b/js/MsgDefinitionInputUiController.js new file mode 100644 index 0000000..3e70506 --- /dev/null +++ b/js/MsgDefinitionInputUiController.js @@ -0,0 +1,897 @@ +/* +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 { DialogBox } from './DomWidgets.js'; +import { StrAccumulator } from '/js/Utl.js'; +import { WsprCodecMaker } from '../../../../pro/codec/WsprCodec.js'; + + + + + +export class MsgDefinitionInputUiController +{ + constructor() + { + this.codecMaker = new WsprCodecMaker(); + + this.onApplyCbFn = () => {}; + this.onErrCbFn = () => {}; + + this.ok = true; + this.cachedLastMsgDefApplied = ""; + + this.namePrefix = "Message Definition Analysis"; + this.name = ""; + + this.fileNamePart = ""; + + this.ui = this.#MakeUI(); + + this.#SetUpEvents(); + this.#ShowExampleValue(); + } + + SetDisplayName(name) + { + this.name = name; + + this.dialogBox.SetTitleBar(this.namePrefix + (this.name == "" ? "" : ` - ${this.name}`)); + } + + SetDownloadFileNamePart(fileNamePart) + { + this.fileNamePart = fileNamePart; + } + + GetUI() + { + return this.ui; + } + + GetUIInput() + { + return this.msgDefInput; + } + + GetUIAnalysis() + { + return this.codecAnalysis; + } + + GetUIButtonApply() + { + return this.applyButton; + } + + GetUIButtonRestore() + { + return this.restoreButton; + } + + GetUIButtonShowExample() + { + return this.showExampleButton; + } + + GetUIButtonFromFile() + { + return this.uploadButton; + } + + GetUIButtonPrettify() + { + return this.prettifyButton; + } + + GetUIButtonToFile() + { + return this.downloadButton; + } + + PrettifyMsgDefinition() + { + let prettyText = this.#BuildPrettifiedMsgDefinitionText(); + if (!prettyText) + { + return false; + } + + let wasApplied = this.GetMsgDefinitionRaw() == this.cachedLastMsgDefApplied; + this.msgDefInput.value = prettyText; + this.#OnMsgDefInputChange(); + + if (this.ok && wasApplied) + { + this.cachedLastMsgDefApplied = prettyText; + this.#MarkMsgDefApplied(); + this.#SetStateApplied(); + this.onApplyCbFn(); + } + + this.onErrCbFn(this.ok); + return this.ok; + } + + SetModeNoPopup() + { + // remove show/hide button + this.ui.removeChild(this.analysisButton); + + // remove dialog box + this.ui.removeChild(this.dialogBox.GetUI()); + + // insert analysis + this.codecAnalysis.style.marginTop = "3px"; + this.ui.append(this.codecAnalysis); + + return this.ui; + } + + SetModeIndividual() + { + // git rid of styling which doesn't apply + this.msgDefInput.style.marginBottom = "0px"; + + // remove show/hide button + this.ui.removeChild(this.analysisButton); + + // remove dialog box + this.ui.removeChild(this.dialogBox.GetUI()); + } + + SetOnApplyCallback(cb) + { + this.onApplyCbFn = cb; + } + + GetOnApplyCallback() + { + return this.onApplyCbFn; + } + + SetOnErrStateChangeCallback(cb) + { + this.onErrCbFn = cb; + } + + IsOk() + { + return this.ok; + } + + GetMsgDefinition() + { + return this.cachedLastMsgDefApplied; + } + + GetMsgDefinitionRaw() + { + return this.msgDefInput.value; + } + + GetFieldList() + { + let c = this.codecMaker.GetCodecInstance(); + const fieldList = c.GetFieldList(); + + return fieldList; + } + + GetFieldNameList() + { + const fieldList = this.GetFieldList(); + + let fieldNameList = []; + for (let field of fieldList) + { + fieldNameList.push(`${field.name}${field.unit}`); + } + + return fieldNameList; + } + + SetMsgDefinition(value, markApplied) + { + markApplied = markApplied ?? true; + + this.msgDefInput.value = value; + + this.#OnMsgDefInputChange(); + + if (this.ok) + { + if (markApplied) + { + this.cachedLastMsgDefApplied = value; + this.#MarkMsgDefApplied(); + this.#SetStateApplied(); + } + } + else + { + // it's bad, so indicate that whatever the prior applied value + // was is still in effect + this.#DisableApplyButton(); + } + + this.onErrCbFn(this.ok); + + return this.ok; + } + + #SetUpEvents() + { + this.msgDefInput.addEventListener('input', () => { + this.#OnMsgDefInputChange(); + }) + + this.applyButton.addEventListener('click', () => { + if (this.ok) + { + this.cachedLastMsgDefApplied = this.GetMsgDefinitionRaw(); + + this.#MarkMsgDefApplied(); + this.#SetStateApplied(); + + this.onApplyCbFn(); + } + }); + + this.restoreButton.addEventListener('click', () => { + this.SetMsgDefinition(this.cachedLastMsgDefApplied, false); + }); + + this.showExampleButton.addEventListener('click', () => { + this.#ShowExampleValue(); + this.#OnMsgDefInputChange(); + }); + + this.uploadButton.addEventListener('click', () => { + utl.LoadFromFile(".json").then((str) => { + this.SetMsgDefinition(str, false); + }); + }); + + this.prettifyButton.addEventListener('click', () => { + this.PrettifyMsgDefinition(); + }); + + this.downloadButton.addEventListener('click', () => { + let fileName = `MsgDef`; + if (this.fileNamePart != "") + { + fileName += `_`; + fileName += this.fileNamePart; + } + fileName += `.json`; + + utl.SaveToFile(this.GetMsgDefinitionRaw(), fileName); + }); + + this.analysisButton.addEventListener('click', () => { + this.dialogBox.ToggleShowHide(); + }); + + utl.GiveHotkeysVSCode(this.msgDefInput, () => { + this.applyButton.click(); + }); + } + + GetExampleValue() + { + let msgDefRowList = [ + `// Example Message Definition -- modify then save!\n`, + `{ "name": "Altitude", "unit": "Meters", "lowValue": 0, "highValue": 21340, "stepSize": 20 },`, + `{ "name": "SatsUSA", "unit": "Count", "lowValue": 0, "highValue": 32, "stepSize": 4 },`, + `{ "name": "ADC1", "unit": "Volts", "lowValue": 2.5, "highValue": 5.5, "stepSize": 0.2 },`, + `{ "name": "SomeInteger", "unit": "Value", "lowValue": -10, "highValue": 110, "stepSize": 5 },`, + `{ "name": "SomeFloat", "unit": "Value", "lowValue": -10.5, "highValue": 9.5, "stepSize": 20 },`, + `{ "name": "ClockDrift", "unit": "Millis", "valueSegmentList": [[-25, 5, -5], [-5, 1, 5], [5, 5, 25]] },`, + ]; + + let str = ``; + let sep = ""; + for (let msgDefRow of msgDefRowList) + { + str += sep; + str += msgDefRow; + + sep = "\n"; + } + + return str; + } + + #ShowExampleValue() + { + this.SetMsgDefinition(this.GetExampleValue(), false); + } + + #OnMsgDefInputChange() + { + this.ok = this.#ApplyMsgDefinition(); + + // handle setting the validity state + if (this.ok) + { + this.#MarkMsgDefValid(); + + // handle setting the applied state + // (this can override the msg def coloring) + if (this.GetMsgDefinitionRaw() == this.cachedLastMsgDefApplied) + { + this.#SetStateApplied(); + } + else + { + this.#SetStateNotApplied(); + } + } + else + { + this.#MarkMsgDefInvalid(); + this.#DisableApplyButton(); + } + + this.onErrCbFn(this.ok); + + return this.ok; + } + + #MarkMsgDefValid() + { + this.msgDefInput.style.backgroundColor = "rgb(235, 255, 235)"; + this.restoreButton.disabled = false; + } + + #MarkMsgDefInvalid() + { + this.msgDefInput.style.backgroundColor = "lightpink"; + this.restoreButton.disabled = false; + } + + #MarkMsgDefApplied() + { + this.msgDefInput.style.backgroundColor = "white"; + this.restoreButton.disabled = true; + } + + #DisableApplyButton() + { + this.applyButton.disabled = true; + } + + #SetStateApplied() + { + this.#DisableApplyButton(); + this.restoreButton.disabled = false; + + this.#MarkMsgDefApplied(); + } + + #SetStateNotApplied() + { + this.applyButton.disabled = false; + } + + #CheckMsgDefOk() + { + let ok = this.codecMaker.SetCodecDefFragment("MyMessageType", this.msgDefInput.value); + + return ok; + } + + #ApplyMsgDefinition() + { + let ok = this.#CheckMsgDefOk(); + + ok &= this.#DoMsgDefinitionAnalysis(ok); + + return ok; + } + + #DoMsgDefinitionAnalysis(codecOk) + { + let retVal = true; + + if (codecOk) + { + // get msg data + const fieldList = this.codecMaker.GetCodecInstance().GetFieldList(); + + // calc max field length for formatting + let maxFieldName = 5; + for (let field of fieldList) + { + let fieldName = field.name + field.unit; + + if (fieldName.length > maxFieldName) + { + maxFieldName = fieldName.length; + } + } + + // analyze utilization + let sumBits = 0; + for (let field of fieldList) + { + sumBits += field.Bits; + } + + // output + const ENCODABLE_BITS = this.codecMaker.GetFieldBitsAvailable(); + let pctUsed = (sumBits * 100 / ENCODABLE_BITS); + + let pctUsedErr = ""; + + if (sumBits > ENCODABLE_BITS) + { + retVal = false; + + pctUsedErr = "<---- OVERFLOW ERR"; + } + + let bitsRemaining = ENCODABLE_BITS - sumBits; + + if (bitsRemaining < 0) { bitsRemaining = 0; } + let pctRemaining = (bitsRemaining * 100 / ENCODABLE_BITS); + + // determine the number of values that could be encoded in the remaining bits, if any + let values = Math.pow(2, bitsRemaining); + if (bitsRemaining < 1) + { + values = 0; + } + let valuesFloor = Math.floor(values); + + // setTimeout(() => { + // console.log(`------`) + // for (let field of fieldList) + // { + // console.log(`${field.name}${field.unit}: ${field.Bits} bits`); + // } + + // console.log(`Encodable bits: ${ENCODABLE_BITS}`); + // console.log(`Sum bits: ${sumBits}`); + // console.log(`Bits remaining: ${bitsRemaining}`); + // console.log(`Values that could be encoded in remaining bits: ${values}`); + // console.log(`Values (floor) that could be encoded in remaining bits: ${valuesFloor}`); + // }, 0); + + let valuesStr = ` (${utl.Commas(0).padStart(11)} values)`; + if (bitsRemaining >= 1) + { + valuesStr = ` (${utl.Commas(valuesFloor).padStart(11)} values)`; + } + + // put out to 3 decimal places because available bits is 29.180... and so + // no need to worry about rounding after the 29.180 portion, so just display + // it and move on. + + let a = new StrAccumulator(); + let valuesAvailable = utl.Commas(Math.floor(Math.pow(2, ENCODABLE_BITS))); + a.A(`Encodable Bits Available: ${ENCODABLE_BITS.toFixed(3).padStart(6)} (${valuesAvailable.padStart(6)} values)`); + a.A(`Encodable Bits Used : ${sumBits.toFixed(3).padStart(6)} (${pctUsed.toFixed(2).padStart(6)} %) ${pctUsedErr}`); + a.A(`Encodable Bits Remaining: ${(bitsRemaining).toFixed(3).padStart(6)} (${pctRemaining.toFixed(2).padStart(6)} %)${valuesStr}`); + + let PAD_VALUES = 9; + let PAD_BITS = 6; + let PAD_AVAIL = 8; + + let FnOutput = (name, numValues, numBits, pct) => { + a.A(`${name.padEnd(maxFieldName)} ${numValues.padStart(PAD_VALUES)} ${numBits.padStart(PAD_BITS)} ${pct.padStart(PAD_AVAIL)}`); + } + + a.A(``); + FnOutput("Field", "# Values", "# Bits", "% Used"); + a.A(`-`.repeat(maxFieldName) + `-`.repeat(PAD_VALUES) + `-`.repeat(PAD_BITS) + `-`.repeat(PAD_AVAIL) + `-`.repeat(9)); + let fieldRowList = []; + for (let field of fieldList) + { + let fieldName = field.name + field.unit; + let pct = (field.Bits * 100 / ENCODABLE_BITS).toFixed(2); + + fieldRowList.push({ + field, + fieldJsonText: this.#GetRawFieldJsonText(field), + fieldName, + numValues: field.NumValues.toString(), + bits: field.Bits.toFixed(3).toString(), + pct, + }); + } + + this.#SetCodecAnalysisWithFieldRows(a.Get(), fieldRowList, maxFieldName, PAD_VALUES, PAD_BITS, PAD_AVAIL); + } + else + { + retVal = false; + + let a = new StrAccumulator(); + a.A(`Codec definition invalid. (Make sure all rows have a trailing comma)`); + a.A(``); + for (let err of this.codecMaker.GetErrList()) + { + a.A(err); + } + this.#SetCodecAnalysisPlain(a.Get()); + } + + return retVal; + } + + #SetCodecAnalysisPlain(text) + { + this.codecAnalysis.replaceChildren(document.createTextNode(text)); + } + + #SetCodecAnalysisWithFieldRows(prefixText, fieldRowList, maxFieldName, PAD_VALUES, PAD_BITS, PAD_AVAIL) + { + this.codecAnalysis.replaceChildren(); + this.codecAnalysis.appendChild(document.createTextNode(prefixText)); + + for (let row of fieldRowList) + { + let line = document.createElement("div"); + line.style.whiteSpace = "pre"; + line.style.fontFamily = "inherit"; + line.style.fontSize = "inherit"; + line.style.lineHeight = "inherit"; + + let fieldNamePadding = " ".repeat(Math.max(0, maxFieldName - row.fieldName.length)); + let suffix = ` ${row.numValues.padStart(PAD_VALUES)} ${row.bits.padStart(PAD_BITS)} ${row.pct.padStart(PAD_AVAIL)}`; + + let link = document.createElement("a"); + link.href = this.#GetSegmentedFieldCalculatorUrl(row.field, row.fieldJsonText); + link.target = "_blank"; + link.rel = "noopener noreferrer"; + link.textContent = row.fieldName; + link.style.fontFamily = "inherit"; + link.style.fontSize = "inherit"; + link.style.lineHeight = "inherit"; + link.style.display = "inline"; + line.appendChild(link); + line.appendChild(document.createTextNode(fieldNamePadding)); + + line.appendChild(document.createTextNode(suffix)); + this.codecAnalysis.appendChild(line); + } + } + + #NormalizeFieldJsonForCompare(field) + { + if (!field || typeof field !== "object") + { + return null; + } + + let normalized = { + name: String(field.name ?? "").trim(), + unit: String(field.unit ?? "").trim(), + }; + + if (Array.isArray(field.valueSegmentList)) + { + normalized.valueSegmentList = field.valueSegmentList.map((segment) => Array.isArray(segment) ? segment.map((value) => Number(value)) : segment); + } + else + { + normalized.lowValue = Number(field.lowValue); + normalized.highValue = Number(field.highValue); + normalized.stepSize = Number(field.stepSize); + } + + return JSON.stringify(normalized); + } + + #GetRawFieldJsonText(field) + { + const target = this.#NormalizeFieldJsonForCompare(field); + if (!target) + { + return ""; + } + + const lineList = this.GetMsgDefinitionRaw().split("\n"); + for (const rawLine of lineList) + { + const trimmed = rawLine.trim(); + if (!trimmed || trimmed.startsWith("//")) + { + continue; + } + + try + { + const parsed = JSON.parse(trimmed.replace(/,\s*$/, "")); + if (this.#NormalizeFieldJsonForCompare(parsed) === target) + { + return trimmed.replace(/,\s*$/, ""); + } + } + catch + { + // Ignore non-JSON lines. + } + } + + return ""; + } + + #GetSegmentedFieldCalculatorUrl(field, fieldJsonText = "") + { + let fieldJson = Array.isArray(field?.valueSegmentList) + ? { + name: field.name, + unit: field.unit, + valueSegmentList: field.valueSegmentList, + } + : { + name: field.name, + unit: field.unit, + lowValue: field.lowValue, + highValue: field.highValue, + stepSize: field.stepSize, + }; + + const exactFieldJsonText = fieldJsonText || this.#GetRawFieldJsonText(field); + return `/pro/codec/fieldcalc/?fieldJson=${encodeURIComponent(exactFieldJsonText || JSON.stringify(fieldJson))}`; + } + + #ParsePrettifyFieldRowList() + { + let fieldRowList = []; + for (let rawLine of this.GetMsgDefinitionRaw().split("\n")) + { + let trimmed = rawLine.trim(); + if (!trimmed || trimmed.startsWith("//")) + { + continue; + } + + try + { + let parsed = JSON.parse(trimmed.replace(/,\s*$/, "")); + if (!parsed || typeof parsed != "object" || Array.isArray(parsed)) + { + continue; + } + + if (typeof parsed.name != "string" || typeof parsed.unit != "string") + { + continue; + } + + if (Array.isArray(parsed.valueSegmentList)) + { + fieldRowList.push({ + type: "segmented", + name: parsed.name, + unit: parsed.unit, + valueSegmentList: parsed.valueSegmentList, + }); + } + else if (typeof parsed.lowValue == "number" && typeof parsed.highValue == "number" && typeof parsed.stepSize == "number") + { + fieldRowList.push({ + type: "uniform", + name: parsed.name, + unit: parsed.unit, + lowValue: parsed.lowValue, + highValue: parsed.highValue, + stepSize: parsed.stepSize, + }); + } + } + catch + { + return []; + } + } + + return fieldRowList; + } + + #FormatSegmentListOneLine(valueSegmentList) + { + return `[${valueSegmentList + .map((segment) => `[${segment.map((value) => Number(value).toString()).join(", ")}]`) + .join(", ")}]`; + } + + #BuildAlignedFieldPart(key, valueText, keyWidth, valueWidth = 0, align = "left", withComma = true, padBeforeComma = true) + { + let keyText = `"${key}":`; + let rawValueText = String(valueText); + let finalValueText = rawValueText; + if (valueWidth > 0) + { + if (align == "right" || padBeforeComma) + { + finalValueText = align == "right" + ? rawValueText.padStart(valueWidth) + : rawValueText.padEnd(valueWidth); + } + } + + if (align == "left" && valueWidth > 0 && padBeforeComma == false) + { + let textWithComma = `${rawValueText}${withComma ? "," : ""}`; + return `${keyText} ${textWithComma.padEnd(valueWidth + (withComma ? 1 : 0))}`; + } + + return `${keyText} ${finalValueText}${withComma ? "," : ""}`; + } + + #BuildPrettifiedMsgDefinitionText() + { + if (this.#CheckMsgDefOk() == false) + { + return ""; + } + + let fieldRowList = this.#ParsePrettifyFieldRowList(); + if (!fieldRowList.length) + { + return ""; + } + + let nameValueList = fieldRowList.map((field) => JSON.stringify(field.name)); + let unitValueList = fieldRowList.map((field) => JSON.stringify(field.unit)); + let lowValueList = fieldRowList.filter((field) => field.type == "uniform").map((field) => Number(field.lowValue).toString()); + let highValueList = fieldRowList.filter((field) => field.type == "uniform").map((field) => Number(field.highValue).toString()); + let stepValueList = fieldRowList.filter((field) => field.type == "uniform").map((field) => Number(field.stepSize).toString()); + let maxNameValueWidth = Math.max(...nameValueList.map((part) => part.length)); + let maxUnitValueWidth = Math.max(...unitValueList.map((part) => part.length)); + let maxLowValueWidth = lowValueList.length ? Math.max(...lowValueList.map((part) => part.length)) : 0; + let maxHighValueWidth = highValueList.length ? Math.max(...highValueList.map((part) => part.length)) : 0; + let maxStepValueWidth = stepValueList.length ? Math.max(...stepValueList.map((part) => part.length)) : 0; + let maxFirstKeyWidth = Math.max(`"name":`.length, `"unit":`.length); + let namePartWidth = Math.max(...nameValueList.map((value) => this.#BuildAlignedFieldPart("name", value, maxFirstKeyWidth, maxNameValueWidth, "left", true, false).length)); + let unitPartWidth = Math.max(...unitValueList.map((value) => this.#BuildAlignedFieldPart("unit", value, maxFirstKeyWidth, maxUnitValueWidth, "left", true, false).length)); + let lineBodyList = fieldRowList.map((field, index) => { + let namePart = this.#BuildAlignedFieldPart("name", nameValueList[index], maxFirstKeyWidth, maxNameValueWidth, "left", true, false).padEnd(namePartWidth); + let unitPart = this.#BuildAlignedFieldPart("unit", unitValueList[index], maxFirstKeyWidth, maxUnitValueWidth, "left", true, false).padEnd(unitPartWidth); + + if (field.type == "segmented") + { + let segmentListText = this.#FormatSegmentListOneLine(field.valueSegmentList); + let thirdPart = this.#BuildAlignedFieldPart("valueSegmentList", segmentListText, 0, 0, "left", false); + return `{ ${namePart} ${unitPart} ${thirdPart}`; + } + + let thirdPart = this.#BuildAlignedFieldPart("lowValue", Number(field.lowValue).toString(), 0, maxLowValueWidth, "right", true); + let fourthPart = this.#BuildAlignedFieldPart("highValue", Number(field.highValue).toString(), 0, maxHighValueWidth, "right", true); + let fifthPart = this.#BuildAlignedFieldPart("stepSize", Number(field.stepSize).toString(), 0, maxStepValueWidth, "right", false); + return `{ ${namePart} ${unitPart} ${thirdPart} ${fourthPart} ${fifthPart}`; + }); + + let maxBodyWidth = Math.max(...lineBodyList.map((line) => line.length)); + let finalLineList = lineBodyList.map((line) => `${line.padEnd(maxBodyWidth)} },`); + return finalLineList.join("\n"); + } + + #MakeUI() + { + // main ui + let ui = document.createElement('div'); + ui.style.boxSizing = "border-box"; + // ui.style.border = "3px solid red"; + + // input for msg definitions + this.msgDefInput = this.#MakeMsgDefInput(); + this.msgDefInput.style.marginBottom = "3px"; + ui.appendChild(this.msgDefInput); + + // make apply button + this.applyButton = document.createElement('button'); + this.applyButton.innerHTML = "Apply"; + ui.appendChild(this.applyButton); + + ui.appendChild(document.createTextNode(' ')); + + // make restore last button + this.restoreButton = document.createElement('button'); + this.restoreButton.innerHTML = "Restore Last Applied"; + ui.appendChild(this.restoreButton); + + ui.appendChild(document.createTextNode(' ')); + + // make show example button + this.showExampleButton = document.createElement('button'); + this.showExampleButton.innerHTML = "Show Example"; + ui.appendChild(this.showExampleButton); + + ui.appendChild(document.createTextNode(' ')); + + // button to prettify the msg def + this.prettifyButton = document.createElement('button'); + this.prettifyButton.innerHTML = "Prettify"; + ui.appendChild(this.prettifyButton); + + ui.appendChild(document.createTextNode(' ')); + + // button to upload a msg def json file + this.uploadButton = document.createElement('button'); + this.uploadButton.innerHTML = "From File"; + ui.appendChild(this.uploadButton); + + ui.appendChild(document.createTextNode(' ')); + + // button to download the msg def into a json file + this.downloadButton = document.createElement('button'); + this.downloadButton.innerHTML = "To File"; + ui.appendChild(this.downloadButton); + + ui.appendChild(document.createTextNode(' ')); + + // button to show/hide msg def analysis + this.analysisButton = document.createElement('button'); + this.analysisButton.innerHTML = "Show/Hide Analysis"; + ui.appendChild(this.analysisButton); + + // msg def analysis + this.codecAnalysis = this.#MakeCodecAnalysis(); + + // dialog for showing msg def analysis + this.dialogBox = new DialogBox(); + ui.appendChild(this.dialogBox.GetUI()); + this.dialogBox.SetTitleBar(this.namePrefix + (this.name == "" ? "" : ` - ${this.name}`)); + this.dialogBox.GetContentContainer().appendChild(this.codecAnalysis); + + return ui; + } + + #MakeMsgDefInput() + { + let dom = document.createElement('textarea'); + dom.style.boxSizing = "border-box"; + dom.spellcheck = false; + dom.style.backgroundColor = "white"; + dom.placeholder = "// Message Definition goes here"; + + // I want it to take up a row by itself + dom.style.display = "block"; + + dom.style.minWidth = "800px"; + dom.style.minHeight = "150px"; + + return dom; + } + + #MakeCodecAnalysis() + { + let dom = document.createElement('div'); + dom.style.boxSizing = "border-box"; + dom.style.backgroundColor = "rgb(234, 234, 234)"; + dom.style.fontFamily = "monospace"; + dom.style.whiteSpace = "pre-wrap"; + dom.style.overflow = "auto"; + dom.style.padding = "2px"; + dom.style.border = "1px solid rgb(118, 118, 118)"; + dom.style.resize = "both"; + dom.style.width = "500px"; + dom.style.height = "190px"; + dom.style.cursor = "default"; + + // make it so flex column container sees this as a whole row + dom.style.display = "block"; + + dom.style.minWidth = "500px"; + dom.style.minHeight = "190px"; + + return dom; + } +} + + + + diff --git a/js/SpotMap.js b/js/SpotMap.js new file mode 100644 index 0000000..292d062 --- /dev/null +++ b/js/SpotMap.js @@ -0,0 +1,2134 @@ +/* +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 { Animation } from './Animation.js' +import { AsyncResourceLoader } from './AsyncResourceLoader.js'; +import { Base } from './Base.js'; +import { CSSDynamic } from './CSSDynamic.js'; +import { DialogBox } from './DomWidgets.js'; +import { Timeline } from '/js/Timeline.js'; +import { WSPREncoded } from '/js/WSPREncoded.js'; + +let t = new Timeline(); +t.SetCcGlobal(true); +t.Event(`SpotMap::AsyncModuleResourceLoad Start`); +let p1 = AsyncResourceLoader.AsyncLoadScript(`https://cdn.jsdelivr.net/npm/ol@v7.3.0/dist/ol.js`); +let p2 = AsyncResourceLoader.AsyncLoadStylesheet(`https://cdn.jsdelivr.net/npm/ol@v7.3.0/ol.css`); +await Promise.all([p1, p2]); +t.Event(`SpotMap::AsyncModuleResourceLoad End`); + + +export class Spot +{ + constructor(spotData) + { + this.spotData = spotData; + + this.loc = ol.proj.fromLonLat([this.GetLng(), this.GetLat()]); + } + + GetLoc() + { + return this.loc; + } + + GetLat() + { + return this.spotData.lat; + } + + GetLng() + { + return this.spotData.lng; + } + + GetGrid() + { + return this.spotData.grid; + } + + GetGrid4() + { + return this.GetGrid().substr(0, 4); + } + + GetAccuracy() + { + return this.spotData.accuracy; + } + + GetDTLocal() + { + return this.spotData.dtLocal; + } + + GetSeenDataList() + { + return this.spotData.seenDataList; + } +} + + +// https://openlayers.org/en/latest/examples/custom-controls.html +function GetStoredBoolean(storageKey, defaultValue, legacyKeyList = []) +{ + for (const key of [storageKey, ...legacyKeyList]) + { + const value = localStorage.getItem(key); + if (value != null) + { + return value == "true"; + } + } + + return defaultValue; +} + +class MapButtonControl extends ol.control.Control { + constructor({ label, rightPx, title = "" }) + { + const button = document.createElement('button'); + button.innerHTML = label; + button.title = title; + button.style.fontWeight = "bold"; + button.style.cursor = "pointer"; + + const element = document.createElement('div'); + element.className = 'ol-unselectable ol-control'; + element.style.top = "7px"; + element.style.right = `${rightPx}px`; + element.appendChild(button); + + super({ + element: element, + }); + + this.button = button; + } + + SetActive(tf) + { + if (tf) + { + this.button.style.backgroundColor = "rgb(215, 237, 255)"; + this.button.style.borderColor = "rgb(120, 160, 210)"; + this.button.style.color = "rgb(25, 55, 95)"; + this.button.style.boxShadow = "inset 0 1px 4px rgba(0, 0, 0, 0.22)"; + this.button.style.transform = "translateY(1px)"; + } + else + { + this.button.style.backgroundColor = "rgb(248, 248, 248)"; + this.button.style.borderColor = "rgb(180, 180, 180)"; + this.button.style.color = "rgb(70, 70, 70)"; + this.button.style.boxShadow = "0 1px 1px rgba(255, 255, 255, 0.8) inset"; + this.button.style.transform = ""; + } + } +} + +class MapToggleControl extends MapButtonControl { + constructor({ label, rightPx, title, storageKey, defaultValue, legacyKeyList = [], onToggle }) + { + super({ label, rightPx, title }); + + this.storageKey = storageKey; + this.onToggle = onToggle; + this.enabled = GetStoredBoolean(storageKey, defaultValue, legacyKeyList); + + this.SetActive(this.enabled); + + this.button.addEventListener('click', () => { + this.SetEnabled(!this.enabled); + }); + } + + SetEnabled(enabled) + { + this.enabled = enabled; + this.SetActive(this.enabled); + localStorage.setItem(this.storageKey, this.enabled); + this.onToggle(this.enabled); + } + + IsEnabled() + { + return this.enabled; + } +} + +class MapHelpControl extends MapButtonControl { + constructor(spotMap) + { + super({ + label: '?', + rightPx: 30, + title: 'Map control help', + }); + + this.button.addEventListener('click', () => { + spotMap.ShowControlHelpDialog(); + }); + } +} + +export class SpotMap +extends Base +{ + static STORAGE_KEY_SHOW_LINES = "spotMapShowLines"; + static STORAGE_KEY_SHOW_RX = "spotMapShowRx"; + static STORAGE_KEY_AUTO_MOVE = "spotMapAutoMove"; + static STORAGE_KEY_HOVER_EMPHASIS = "spotMapHoverEmphasis"; + + constructor(cfg) + { + super(); + + this.cfg = cfg; + this.container = this.cfg.container; + + this.t.Event("SpotMap::Constructor"); + + // Initial state of map + this.initialCenterLocation = ol.proj.fromLonLat([-40, 40]); + this.initialZoom = 1; + + this.dataSetPreviously = false; + + this.dt__data = new Map(); + this.spotDt__idx = new Map(); + this.spot__rxFeatureList = new WeakMap(); + this.spotFeatureList = []; + this.lineFeatureList = []; + this.lineFeatureListByEndIdx = []; + this.hoverStartIdxBySpotIdx = []; + this.currentHoverWindow = null; + this.currentHoverSpotDt = null; + this.spotOpacityList = []; + this.lineOpacityByEndIdx = []; + this.spotStyleCache = new Map(); + this.lineStyleCache = new Map(); + this.rxFeatureListKeyLast = null; + this.pendingPointerMove = null; + this.pointerMoveRafId = null; + this.hoverSpotDt = null; + this.rxStyleSeen = new ol.style.Style({ + image: new ol.style.Circle({ + radius: 3, + fill: new ol.style.Fill({ + color: 'rgba(255, 0, 255, 1)', + }), + stroke: new ol.style.Stroke({ + color: 'rgba(255, 0, 255, 1)', + width: 0.1, + }), + }), + }); + + this.spotListLast = []; + this.showLines = GetStoredBoolean(SpotMap.STORAGE_KEY_SHOW_LINES, true); + this.showRxEnabled = GetStoredBoolean(SpotMap.STORAGE_KEY_SHOW_RX, true, ["showRxState"]); + this.autoMove = GetStoredBoolean(SpotMap.STORAGE_KEY_AUTO_MOVE, true); + this.hoverEmphasisEnabled = GetStoredBoolean(SpotMap.STORAGE_KEY_HOVER_EMPHASIS, true); + + this.mapControl = new MapToggleControl({ + label: 'L', + rightPx: 53, + title: 'Toggle lines between spots', + storageKey: SpotMap.STORAGE_KEY_SHOW_LINES, + defaultValue: true, + onToggle: enabled => this.OnLineToggle(enabled), + }); + this.mapControlRx = new MapToggleControl({ + label: 'R', + rightPx: 76, + title: 'Toggle receiver markers', + storageKey: SpotMap.STORAGE_KEY_SHOW_RX, + defaultValue: true, + legacyKeyList: ["showRxState"], + onToggle: enabled => this.OnRxToggle(enabled), + }); + this.mapControlMove = new MapToggleControl({ + label: 'M', + rightPx: 99, + title: 'Toggle map movement to new spots', + storageKey: SpotMap.STORAGE_KEY_AUTO_MOVE, + defaultValue: true, + onToggle: enabled => this.OnMoveToggle(enabled), + }); + this.mapControlHover = new MapToggleControl({ + label: 'H', + rightPx: 122, + title: 'Toggle hover emphasis', + storageKey: SpotMap.STORAGE_KEY_HOVER_EMPHASIS, + defaultValue: true, + onToggle: enabled => this.OnHoverEmphasisToggle(enabled), + }); + this.mapControlHelp = new MapHelpControl(this); + this.showRxState = this.showRxEnabled ? "default" : "disabled"; + + this.MakeUI(); + this.controlHelpDialog = this.MakeControlHelpDialog(); + document.body.appendChild(this.controlHelpDialog.GetUI()); + this.Load(); + this.SetupEscapeHandler(); + } + + SetDebug(tf) + { + this.t.SetCcGlobal(tf); + } + + MakeUI() + { + let cd = new CSSDynamic(); + + this.ui = document.createElement('div'); + this.ui.style.margin = "0px"; + this.ui.style.padding = "0px"; + this.ui.style.width = "100%"; + this.ui.style.height = "100%" + this.ui.style.position = "relative"; + this.ui.style.opacity = "0.0"; // initially + + this.container.appendChild(this.ui); + + this.hoverInfoUi = document.createElement('div'); + this.hoverInfoUi.style.position = "absolute"; + this.hoverInfoUi.style.top = "34px"; + this.hoverInfoUi.style.right = "30px"; + this.hoverInfoUi.style.fontSize = "11px"; + this.hoverInfoUi.style.color = "rgba(40, 40, 40, 0.9)"; + this.hoverInfoUi.style.pointerEvents = "none"; + this.hoverInfoUi.style.backgroundColor = "rgba(255, 255, 255, 0.0)"; + this.hoverInfoUi.style.zIndex = "2"; + this.hoverInfoUi.style.display = "none"; + this.hoverInfoUi.style.whiteSpace = "pre"; + this.ui.appendChild(this.hoverInfoUi); + + // create and style the entire popup + this.popup = document.createElement('div'); + this.popup.classList.add('ol-popup'); + cd.SetCssClassProperties(`ol-popup`, { + position: "absolute", + backgroundColor: "white", + boxShadow: "0 1px 4px rgba(0,0,0,0.2)", + padding: "15px", + borderRadius: "10px", + border: "1px solid #cccccc", + bottom: "12px", + left: "-50px", + minWidth: "250px", + zIndex: "-1", + }); + + for (let ccName of ["ol-popup::after", "ol-popup::before"]) + { + cd.SetCssClassProperties(ccName, { + top: "100%", + border: "solid transparent", + content: " ", + height: 0, + width: 0, + position: "absolute", + pointerEvents: "none", + }); + } + cd.SetCssClassDynamicProperties("ol-popup", "after", " ", ` + border-top-color: white; + border-width: 10px; + left: 48px; + margin-left: -10px; + `); + + cd.SetCssClassDynamicProperties("ol-popup", "before", " ", ` + border-top-color: #cccccc; + border-width: 11px; + left: 48px; + margin-left: -11px; + `); + + + // create and style the X button + this.popupCloser = document.createElement('div'); + this.popupCloser.appendChild(document.createTextNode("✖")); + this.popupCloser.style.cursor = "default"; + this.popupCloser.classList.add('ol-popup-closer'); + cd.SetCssClassProperties(`ol-popup-closer`, { + textDecoration: "none", + position: "absolute", + top: "2px", + right: "5px", + }); + + // create container for content + this.popupContent = document.createElement('div'); + + // assemble + this.popup.appendChild(this.popupCloser); + this.popup.appendChild(this.popupContent); + } + + Load() + { + this.MakeMapBase(); + this.MakeMapLayers(); + this.MakeMapOverlay(); + this.SetupEventHandlers(); + } + + SetupEscapeHandler() + { + document.addEventListener('keydown', e => { + if (e.key == 'Escape') + { + this.ClosePopup(); + } + }); + } + + MakeControlHelpDialog() + { + let dlg = new DialogBox(); + dlg.SetTitleBar("Map Controls Help"); + dlg.GetUI().style.top = "100px"; + dlg.GetUI().style.left = "100px"; + + let body = dlg.GetContentContainer(); + body.style.padding = "12px"; + body.style.minWidth = "420px"; + body.style.maxWidth = "560px"; + body.style.backgroundColor = "rgb(245, 245, 245)"; + body.style.fontSize = "14px"; + body.style.lineHeight = "1.45"; + + let intro = document.createElement("div"); + intro.innerHTML = `Use the upper-right map controls to change how spots are displayed.`; + intro.style.marginBottom = "8px"; + intro.style.fontWeight = "600"; + body.appendChild(intro); + + let list = document.createElement("ul"); + list.style.margin = "0px"; + list.style.paddingLeft = "20px"; + + for (const html of [ + `H toggles highlighting the last lap to the hovered point.`, + `M toggles the map panning to new spot locations on update or not.`, + `R toggles showing receivers (purple dots).`, + `L toggles interconnecting the spots with lines.`, + `? opens this help dialog.`, + ]) + { + let li = document.createElement("li"); + li.innerHTML = html; + list.appendChild(li); + } + + body.appendChild(list); + + let note = document.createElement("div"); + note.innerHTML = `These settings are saved and restored automatically.`; + note.style.marginTop = "8px"; + body.appendChild(note); + + return dlg; + } + + ShowControlHelpDialog() + { + this.controlHelpDialog.Show(); + } + + MakeMapBase() + { + // for base raster, we use Open Street Maps + const source = new ol.source.OSM(); + + // let's set up a little mini-map in the lower-left corner + const overviewMapControl = new ol.control.OverviewMap({ + layers: [ + new ol.layer.Tile({ + source: source, + }), + ], + }); + + // set up controls for mini-map + let controls = new ol.Collection(); + + // set up layers + const engOsmLayer = new ol.layer.Tile({ + source: new ol.source.XYZ({ + url: './tiles/{z}/{x}/{y}.png', + attributions: [ + '© OpenStreetMap contributors.' + ] + }), + minZoom: 0, + maxZoom: 7 + }); + + const osmLayer = new ol.layer.Tile({ + source: source, + minZoom: 7 + }); + + // set up attribution + const attributionControl = new ol.control.Attribution({ + collapsible: false + }); + + // Load map instance + this.map = new ol.Map({ + controls: controls.extend([ + overviewMapControl, + new ol.control.FullScreen(), + this.mapControl, + this.mapControlRx, + this.mapControlMove, + this.mapControlHover, + this.mapControlHelp, + attributionControl, + ]), + // target: this.container, + target: this.ui, + layers: [ + engOsmLayer, + osmLayer, + ], + view: new ol.View({ + center: this.initialCenterLocation, + zoom: this.initialZoom, + }), + }); + + // make sure the mini-map is closed by default + overviewMapControl.setCollapsed(true); + } + + HeatMapGetWeight(feature) + { + const grid4 = feature.get("grid4"); + + console.log(`${grid4}`) + + // look up pre-cached data about relative grid reception + let data = this.grid4__data.get(grid4); + + // calculate weight + let retVal = 0.0; + retVal = data.maxHeard / this.maxHeardGlobal; + + retVal = data.maxHeard / 50.0; + if (retVal > 1) { retVal = 1; } + + console.log(`returning ${grid4}: ${data.maxHeard} / ${this.maxHeardGlobal} = ${retVal}`) + return retVal; + } + + MakeMapLayers() + { + // create heat map + // https://gis.stackexchange.com/questions/418820/creating-heatmap-from-vector-tiles-using-openlayers + // + // let heatmapSource = new ol.source.Vector(); + // this.hmLayer = new ol.layer.Heatmap({ + // source: heatmapSource, + // weight: feature => { + // return this.HeatMapGetWeight(feature); + // }, + // radius: 10, + // blur: 30, + // }); + // this.map.addLayer(this.hmLayer); + + // create a layer to put rx station markers on + this.rxLayer = new ol.layer.Vector({ + source: new ol.source.Vector({ + features: [], + }), + }); + this.map.addLayer(this.rxLayer); + + // create a layer to put spot markers on + this.spotLayer = new ol.layer.Vector({ + source: new ol.source.Vector({ + features: [], + }), + }); + this.map.addLayer(this.spotLayer); + } + + MakeMapOverlay() + { + this.overlay = new ol.Overlay({ + // element: document.getElementById('popup'), + element: this.popup, + autoPan: { + animation: { + duration: 250, + }, + }, + }); + + this.map.addOverlay(this.overlay); + + // let closer = document.getElementById('popup-closer'); + // closer.onclick = () => { + this.popupCloser.onclick = () => { + this.ClosePopup(); + this.popupCloser.blur(); + return false; + }; + } + + ClosePopup() + { + if (this.overlay) + { + if (this.showRxState != "disabled") + { + this.showRxState = "default"; + this.HandleSeen(this.spotListLast); + } + + this.overlay.setPosition(undefined); + } + } + + OnLineToggle(enabled) + { + this.showLines = enabled; + + // re-display + this.SetSpotList(this.spotListLast, { preserveView: true }); + } + + OnRxToggle(enabled) + { + this.showRxEnabled = enabled; + this.SetShowRxState(this.showRxEnabled ? "default" : "disabled"); + } + + OnMoveToggle(enabled) + { + this.autoMove = enabled; + } + + OnHoverEmphasisToggle(enabled) + { + this.hoverEmphasisEnabled = enabled; + this.currentHoverWindow = null; + + if (this.hoverEmphasisEnabled) + { + this.PrecomputeHoverEmphasisData(); + } + else + { + this.hoverStartIdxBySpotIdx = []; + } + + this.ApplyHoverEmphasis(); + } + + GetSpotStyle(accuracy, opacity = 1.0) + { + const opacityUse = Math.max(0.07, Math.min(1.0, opacity)); + const key = `${accuracy}|${opacityUse.toFixed(3)}`; + if (this.spotStyleCache.has(key)) + { + return this.spotStyleCache.get(key); + } + + const radius = + accuracy == "veryHigh" ? 3 : + accuracy == "high" ? 5 : + 5; + + const strokeRgb = + accuracy == "veryHigh" ? [55, 143, 205] : + accuracy == "high" ? [55, 143, 205] : + [205, 143, 55]; + + const style = new ol.style.Style({ + image: new ol.style.Circle({ + radius: radius, + fill: new ol.style.Fill({ + color: `rgba(255, 255, 255, ${(0.4 * opacityUse).toFixed(3)})`, + }), + stroke: new ol.style.Stroke({ + color: `rgba(${strokeRgb[0]}, ${strokeRgb[1]}, ${strokeRgb[2]}, ${opacityUse.toFixed(3)})`, + width: 1.1, + }), + }), + }); + + this.spotStyleCache.set(key, style); + + return style; + } + + GetLineStyle(opacity = 1.0) + { + const opacityUse = Math.max(0.07, Math.min(1.0, opacity)); + const key = opacityUse.toFixed(3); + if (this.lineStyleCache.has(key)) + { + return this.lineStyleCache.get(key); + } + + const style = new ol.style.Style({ + stroke: new ol.style.Stroke({ + color: `rgba(0, 128, 0, ${opacityUse.toFixed(3)})`, + width: 1, + }), + }); + + this.lineStyleCache.set(key, style); + + return style; + } + + GetHoverEmphasisWindow() + { + if (this.hoverEmphasisEnabled == false || this.hoverSpotDt == null || this.spotFeatureList.length == 0) + { + return null; + } + + let hoverIdx = this.spotDt__idx.get(this.hoverSpotDt); + if (hoverIdx == undefined) + { + return null; + } + + return { + startIdx: this.hoverStartIdxBySpotIdx[hoverIdx] ?? 0, + hoverIdx: hoverIdx, + }; + } + + GetHoverEmphasisOpacity(spotIdx, window) + { + const DIMMED_OPACITY = 0.07; + + if (window == null) + { + return 1.0; + } + + if (spotIdx > window.hoverIdx || spotIdx < window.startIdx) + { + return DIMMED_OPACITY; + } + + return 1.0; + } + + UpdateHoverInfoUi(window) + { + if (this.hoverSpotDt == null) + { + this.hoverInfoUi.style.display = "none"; + this.hoverInfoUi.textContent = ""; + return; + } + + const hoverIdx = this.spotDt__idx.get(this.hoverSpotDt); + const hoverSpot = hoverIdx == undefined ? null : this.spotFeatureList[hoverIdx]?.get("spot"); + if (!hoverSpot) + { + this.hoverInfoUi.style.display = "none"; + this.hoverInfoUi.textContent = ""; + return; + } + + if (this.hoverEmphasisEnabled && window != null) + { + const spotStart = this.spotFeatureList[window.startIdx]?.get("spot"); + const spotEnd = this.spotFeatureList[window.hoverIdx]?.get("spot"); + if (!spotStart || !spotEnd) + { + this.hoverInfoUi.style.display = "none"; + this.hoverInfoUi.textContent = ""; + return; + } + + const dtStartUtc = utl.ConvertLocalToUtc(spotStart.GetDTLocal()); + const dtEndUtc = utl.ConvertLocalToUtc(spotEnd.GetDTLocal()); + const dtStartLocal = spotStart.GetDTLocal(); + const dtEndLocal = spotEnd.GetDTLocal(); + + this.hoverInfoUi.textContent = + `UTC ${dtStartUtc.slice(0, 16)} - ${dtEndUtc.slice(0, 16)}\n` + + `LCL ${dtStartLocal.slice(0, 16)} - ${dtEndLocal.slice(0, 16)}`; + } + else + { + const dtUtc = utl.ConvertLocalToUtc(hoverSpot.GetDTLocal()); + const dtLocal = hoverSpot.GetDTLocal(); + + this.hoverInfoUi.textContent = + `UTC ${dtUtc.slice(0, 16)}\n` + + `LCL ${dtLocal.slice(0, 16)}`; + } + + this.hoverInfoUi.style.display = ""; + } + + PrecomputeHoverEmphasisData() + { + const TEN_DAYS_MS = 10 * 24 * 60 * 60 * 1000; + + this.hoverStartIdxBySpotIdx = []; + + if (this.hoverEmphasisEnabled == false || this.spotFeatureList.length == 0) + { + return; + } + + let spotMsList = []; + let unwrappedLngList = []; + let lngLast = null; + for (const feature of this.spotFeatureList) + { + spotMsList.push(utl.ParseTimeToMs(feature.get("spot").GetDTLocal())); + + let lng = feature.get("spot").GetLng(); + if (lngLast != null) + { + while ((lng - lngLast) > 180) { lng -= 360; } + while ((lng - lngLast) < -180) { lng += 360; } + } + + unwrappedLngList.push(lng); + lngLast = lng; + } + + let SegmentCrossesEquivalentLongitude = (lngA, lngB, targetLng) => { + let lngMin = Math.min(lngA, lngB); + let lngMax = Math.max(lngA, lngB); + + let kMin = Math.ceil((lngMin - targetLng) / 360); + let kMax = Math.floor((lngMax - targetLng) / 360); + + return kMin <= kMax; + }; + + let minLookbackIdx = 0; + for (let hoverIdx = 0; hoverIdx < unwrappedLngList.length; ++hoverIdx) + { + let hoverMs = spotMsList[hoverIdx]; + let minAllowedMs = hoverMs - TEN_DAYS_MS; + while (minLookbackIdx < hoverIdx && spotMsList[minLookbackIdx] < minAllowedMs) + { + ++minLookbackIdx; + } + + let targetLng = unwrappedLngList[hoverIdx]; + let startIdx = minLookbackIdx; + + for (let i = minLookbackIdx; i >= 1; --i) + { + let lngA = unwrappedLngList[i - 1]; + let lngB = unwrappedLngList[i]; + + if (SegmentCrossesEquivalentLongitude(lngA, lngB, targetLng)) + { + startIdx = i; + break; + } + } + + this.hoverStartIdxBySpotIdx[hoverIdx] = startIdx; + } + } + + ApplyHoverEmphasis() + { + if (this.spotFeatureList.length == 0) + { + this.UpdateHoverInfoUi(null); + return; + } + + const window = this.GetHoverEmphasisWindow(); + const windowSame = + this.currentHoverWindow?.startIdx == window?.startIdx && + this.currentHoverWindow?.hoverIdx == window?.hoverIdx; + const hoverSpotSame = this.currentHoverSpotDt == this.hoverSpotDt; + if (windowSame) + { + if (hoverSpotSame) + { + return; + } + } + + this.UpdateHoverInfoUi(window); + + for (let i = 0; i < this.spotFeatureList.length; ++i) + { + let opacity = this.GetHoverEmphasisOpacity(i, window); + if (this.spotOpacityList[i] != opacity) + { + const feature = this.spotFeatureList[i]; + const spot = feature.get("spot"); + feature.setStyle(this.GetSpotStyle(spot.GetAccuracy(), opacity)); + this.spotOpacityList[i] = opacity; + } + } + + for (let endIdx = 1; endIdx < this.lineFeatureListByEndIdx.length; ++endIdx) + { + const opacity = Math.min( + this.GetHoverEmphasisOpacity(endIdx - 1, window), + this.GetHoverEmphasisOpacity(endIdx, window), + ); + if (this.lineOpacityByEndIdx[endIdx] == opacity) + { + continue; + } + + for (const feature of this.lineFeatureListByEndIdx[endIdx] ?? []) + { + feature.setStyle(this.GetLineStyle(opacity)); + } + this.lineOpacityByEndIdx[endIdx] = opacity; + } + + this.currentHoverWindow = window ? { ...window } : null; + this.currentHoverSpotDt = this.hoverSpotDt; + } + + GetLatestFeatureByType(featureList, type) + { + let featureLatest = null; + let dtLatest = null; + for (const feature of featureList) + { + if (feature.get("type") == type) + { + let dt = feature.get("spot").GetDTLocal(); + if (dtLatest == null || dt > dtLatest) + { + dtLatest = dt; + featureLatest = feature; + } + } + } + + return featureLatest; + } + + GetLatestSpotFeatureAtPixel(pixel) + { + let featureLatest = this.map.forEachFeatureAtPixel(pixel, (feature, layer) => { + if (layer === this.spotLayer && feature.get("type") == "spot") + { + return feature; + } + + return undefined; + }, { + hitTolerance: 4, + layerFilter: layer => layer === this.spotLayer, + }); + + return featureLatest; + } + + OnPointerMove(pixel, coordinate, e) + { + // if map moving (by a fling), a mouseover event causes a noticable + // hang in motion. prevent that by only handling this event if we + // are not in motion. + if (this.moveState == "moving") { return; } + if (this.showRxState == "frozen") { return; } + + // figure out what you're hovering over. + // prioritize mousing over spots. + // + // update - holy shit this takes like 100ms when the dev console + // is open, but seemingly not when it isn't + let spotFeature = this.GetLatestSpotFeatureAtPixel(pixel); + + // accumulate firing of the same spot, and also distinguish between + // hovering over something vs nothing + + if (this.spotFeatureLast == spotFeature) + { + // either still the same something, or still nothing, but either + // way we don't care and ignore it + spotFeature = null; + } + else + { + // there was a change + if (spotFeature) + { + // was nothing, now something + const spot = spotFeature.get("spot"); + + this.hoverSpotDt = spot.GetDTLocal(); + this.ApplyHoverEmphasis(); + + if (this.showRxState != "disabled") + { + this.showRxState = "hover"; + this.HandleSeen([spot]); + } + } + else + { + // was something, now nothing + this.hoverSpotDt = null; + this.ApplyHoverEmphasis(); + + if (this.showRxState != "disabled") + { + this.showRxState = "default"; + this.HandleSeen(this.spotListLast); + } + } + + // remember for next time + this.spotFeatureLast = spotFeature; + } + } + + OnClick(pixel, coordinate, e) + { + let feature = this.GetLatestSpotFeatureAtPixel(pixel); + + if (feature) + { + let spotLast = null; + if (feature) + { + spotLast = feature.get("spot"); + } + + if (spotLast) + { + // if the external click generator passes along the + // specific spot to use, use it instead + if (e.spot) + { + spotLast = e.spot; + } + + // set rx location updates frozen since we know we're + // doing a popup here. the rx locations of this spot + // should already be being shown given the mouse + // clicked it, but let's be explicit anyway + if (this.showRxState != "disabled") + { + // temporarily lift a potential freeze + // (from prior popup click) to show the rx for this + // specific spot + this.showRxState = "hover"; + this.HandleSeen([spotLast]); + + // now freeze + this.showRxState = "frozen"; + } + + // fill out popup + let td = spotLast.spotData.td; + + // let content = document.getElementById('popup-content'); + let content = this.popupContent; + // content.innerHTML = `

You clicked ${td.Get(0, "DateTimeLocal")}

`; + content.innerHTML = ``; + let table = utl.MakeTableTransposed(td.GetDataTable()); + content.appendChild(table); + + // add additional links + let lat = spotLast.GetLat(); + let lng = spotLast.GetLng(); + // get altitude but strip comma from it first + let altM = td.Get(0, "AltM"); + if (altM) + { + altM = altM.toString(); + altM = parseInt(altM.replace(/\,/g,''), 10); + } + else + { + altM = 0; + } + + // make jump link active + let domJl = document.createElement("span"); + domJl.innerHTML = "jump to data"; + domJl.style.cursor = "pointer"; + domJl.style.color = "blue"; + domJl.style.textDecoration = "underline"; + domJl.style.userSelect = "none"; + + domJl.onclick = () => { + window.parent.postMessage({ + type: "JUMP_TO_DATA", + ts: spotLast.GetDTLocal(), + }, "*"); + }; + + // fill out more popup + content.appendChild(domJl); + content.appendChild(document.createElement("br")); + content.appendChild(document.createElement("br")); + content.appendChild(document.createTextNode("Links:")); + content.appendChild(document.createElement("br")); + + // create a table of links to show + let dataTableLinks = [ + ["windy.com", "suncalc.org", "hysplit"] + ]; + let dataRow = []; + + // fill out windy links + let windyLinksList = []; + windyLinksList.push(utl.MakeLink(this.MakeUrlWindyWind(lat, lng, altM), "wind")); + windyLinksList.push(utl.MakeLink(this.MakeUrlWindyCloudtop(lat, lng), "cloudtop")); + windyLinksList.push(utl.MakeLink(this.MakeUrlWindyRain(lat, lng), "rain")); + + let windyLinksStr = windyLinksList.join(", "); + dataRow.push(windyLinksStr); + + // fill out suncalc links + let suncalcLinksList = []; + suncalcLinksList.push(utl.MakeLink(this.MakeUrlSuncalc(lat, lng), "suncalc")); + + let suncalcLinksStr = suncalcLinksList.join(", "); + dataRow.push(suncalcLinksStr); + + // fill out hysplit links + let hysplitLinksList = []; + hysplitLinksList.push(utl.MakeLink(this.MakeUrlHysplitTrajectory(), "traj")); + hysplitLinksList.push(utl.MakeLink(this.MakeUrlHysplitTrajectoryBalloon(), "for balloons")); + + let hysplitLinksStr = hysplitLinksList.join(", "); + dataRow.push(hysplitLinksStr); + + // push data into data table + dataTableLinks.push(dataRow); + + // construct html table and insert + let linksTable = utl.MakeTableTransposed(dataTableLinks); + content.appendChild(linksTable); + + // position + this.overlay.setPosition(coordinate); + } + } + } + + // https://openlayers.org/en/latest/apidoc/module-ol_MapBrowserEvent-MapBrowserEvent.html + SetupEventHandlers() + { + this.map.on('click', e => { + this.OnClick(e.pixel, e.coordinate, e) + }); + + this.map.on('pointermove', e => { + this.pendingPointerMove = e; + + if (this.pointerMoveRafId == null) + { + this.pointerMoveRafId = window.requestAnimationFrame(() => { + let evt = this.pendingPointerMove; + this.pendingPointerMove = null; + this.pointerMoveRafId = null; + + if (evt) + { + this.OnPointerMove(evt.pixel, evt.coordinate, evt); + } + }); + } + }); + + this.moveState = "stopped"; + this.map.on('movestart', e => { + // console.log("move start") + this.moveState = "moving"; + }); + this.map.on('moveend', e => { + // console.log("move end") + this.moveState = "stopped"; + }); + + // this.map.on('precompose', e => { console.log("precompose") }); + // this.map.on('postcompose', e => { console.log("postcompose") }); + // this.map.on('prerender', e => { console.log("prerender") }); + // this.map.on('postrender', e => { console.log("postrender") }); + // this.map.on('rendercomplete', e => { + // console.log("rendercomplete"); + // }); + } + + MakeUrlHysplitTrajectoryBalloon() + { + return `https://www.ready.noaa.gov/hypub-bin/trajsrc.pl?trjtype=4`; + } + + MakeUrlHysplitTrajectory() + { + // save a click from https://www.ready.noaa.gov/HYSPLIT_traj.php + // then https://www.ready.noaa.gov/hypub-bin/trajtype.pl + + return `https://www.ready.noaa.gov/hypub-bin/trajsrc.pl`; + } + + MakeUrlSuncalc(lat, lng) + { + // seems providing a date and a time will set the page to something other than + // "now," but it expects the date and time to be in the local timezone, which I + // have no way of getting (easily). Does not appear to support UTC. + + let mapZoom = 5; + return `https://suncalc.org/#/${lat},${lng},${mapZoom}/null/null/null/null`; + } + + MakeUrlWindyRain(lat, lng) + { + return `https://windy.com/?rain,${lat},${lng},5,d:picker`; + } + + MakeUrlWindyCloudtop(lat, lng) + { + return `https://windy.com/?cloudtop,${lat},${lng},5,d:picker`; + } + + MakeUrlWindyWind(lat, lng, altM) + { + let altLabelList = [ + [0, "surface"], + [100, "100m"], + [600, "950h"], + [750, "925h"], + [900, "900h"], + [1500, "850h"], + [2000, "800h"], + [3000, "700h"], + [4200, "600h"], + [5500, "500h"], + [7000, "400h"], + [9000, "300h"], + [10000, "250h"], + [11700, "200h"], + [13500, "150h"], + ]; + + if (altM < 0) { altM = 0; } + + // determine the correct elevation for the map + let labelUse = null; + for (let [alt, label] of altLabelList) + { + // console.log(`Checking ${altM} against ${alt}, ${label}`); + + if (altM >= alt) + { + labelUse = label; + + // console.log(`using ${labelUse} for now`); + } + } + // console.log(`using ${labelUse} final`); + + // force at least a single decimal place or the page doesn't drop a pin correctly + let latStr = lat.toFixed(9); + let lngStr = lng.toFixed(9); + + return `https://windy.com/?wind,${labelUse},${latStr},${lngStr},5,d:picker`; + } + + SetShowRxState(state) + { + this.showRxState = state; + this.rxFeatureListKeyLast = null; + + this.HandleSeen(this.spotListLast); + } + + HandleSeen(spotList) + { + if (this.showRxState == "frozen") { return ; } + + let source = this.rxLayer.getSource(); + if (this.showRxState == "disabled") + { + source.clear(true); + this.rxFeatureListKeyLast = null; + return; + } + + // decide which rx to show depending on state + let spotListUse = []; + if (this.showRxState == "default") + { + if (spotList.length) + { + spotListUse = [spotList.at(-1)]; + } + } + else + { + spotListUse = spotList; + } + + let featureListKey = this.showRxState + "|" + spotListUse.map(spot => spot.GetDTLocal()).join("|"); + if (featureListKey == this.rxFeatureListKeyLast) + { + return; + } + + let featureList = []; + for (const spot of spotListUse) + { + featureList.push(... this.GetRxFeatureListForSpot(spot)); + } + + source.clear(true); + if (featureList.length) + { + source.addFeatures(featureList); + } + + this.rxFeatureListKeyLast = featureListKey; + } + + GetRxFeatureListForSpot(spot) + { + if (this.spot__rxFeatureList.has(spot)) + { + return this.spot__rxFeatureList.get(spot); + } + + let featureList = []; + for (const seenData of spot.GetSeenDataList()) + { + let pointSeen = new ol.geom.Point(ol.proj.fromLonLat([seenData.lng, seenData.lat])); + + let featureSeen = new ol.Feature({ + geometry: pointSeen, + }); + + featureSeen.setStyle(this.rxStyleSeen); + featureSeen.set("type", "rx"); + featureSeen.set("spot", spot); + + featureList.push(featureSeen); + } + + this.spot__rxFeatureList.set(spot, featureList); + + return featureList; + } + + // function to pre-process spots such that a heat map can be constructed. + // goal is to: + // - break all spots down into grid4 locations + // - sum up all the confirmed spots in each grid 4 + // - or take max? + // - determine the max grid + // - use this as the "top" value by which all others are scaled + // - this data is used to supply the heat map weight (0-1) with a relative + // order + // - heat should avoid giving a metric to every spot in a grid, it'll sum up + // to be too high + // - instead use the "middle"? + HeatMapHandleData(spotList) + { + this.grid4__data = new Map(); + + // group all spots by grid4 + for (const spot of spotList) + { + let grid4 = spot.GetGrid4(); + + if (this.grid4__data.has(grid4) == false) + { + this.grid4__data.set(grid4, { + spotList: [], + maxHeard: 0, + }); + } + + let data = this.grid4__data.get(grid4); + + data.spotList.push(spot); + } + + // find the max per-grid and global grid max + this.maxHeardGlobal = 0; + this.grid4__data.forEach((data, grid4, map) => { + console.log(`grid4 ${grid4}`) + + for (const spot of data.spotList) + { + let heard = spot.GetSeenDataList().length; + + console.log(` dt ${spot.GetDTLocal()} heard ${heard}`); + + if (heard > data.maxHeard) + { + console.log(` that's a new grid max`) + data.maxHeard = heard; + } + + if (heard > this.maxHeardGlobal) + { + console.log(` and a new global max`) + this.maxHeardGlobal = heard; + } + } + }); + + console.log(`global max: ${this.maxHeardGlobal}`) + } + + SetSpotList(spotList, options = {}) + { + const preserveView = options.preserveView ?? false; + + this.t.Reset(); + this.t.Event("SpotMap::SetSpotList Start"); + + // this.HeatMapHandleData(spotList); + + // draw first so spots overlap + this.HandleSeen(spotList); + + // clear old spot features + if (this.dataSetPreviously == true) + { + let FnCount = (thing) => { + let count = 0; + + thing.forEachFeature(t => { + ++count; + }); + + return count; + }; + + // console.log(`clearing ${FnCount(this.spotLayer.getSource())} features`) + this.spotLayer.getSource().clear(true); + // this.hmLayer.getSource().clear(true); + } + + this.spotFeatureList = []; + this.lineFeatureList = []; + this.lineFeatureListByEndIdx = []; + this.spotOpacityList = []; + this.lineOpacityByEndIdx = []; + this.hoverStartIdxBySpotIdx = []; + this.currentHoverWindow = null; + this.currentHoverSpotDt = null; + this.spotDt__idx.clear(); + this.UpdateHoverInfoUi(null); + + // add points + for (let idx = 0; idx < spotList.length; ++idx) + { + const spot = spotList[idx]; + let point = new ol.geom.Point(spot.GetLoc()); + + let feature = new ol.Feature({ + geometry: point, + }); + + feature.setStyle(this.GetSpotStyle(spot.GetAccuracy(), 1.0)); + + feature.set("type", "spot"); + feature.set("spot", spot); + feature.set("spotIndex", idx); + + this.spotLayer.getSource().addFeature(feature); + this.spotFeatureList.push(feature); + this.spotOpacityList[idx] = 1.0; + this.spotDt__idx.set(spot.GetDTLocal(), idx); + } + + if (this.hoverEmphasisEnabled) + { + this.PrecomputeHoverEmphasisData(); + } + + // // heat map driven off of grid4 + // for (const grid4 of this.grid4__data.keys()) + // { + // let [lat, lng] = WSPREncoded.DecodeMaidenheadToDeg(grid4); + + // let point = new ol.geom.Point(ol.proj.fromLonLat([lng, lat])); + + // let feature = new ol.Feature({ + // geometry: point, + // }); + + // feature.set("type", "grid4"); + // feature.set("grid4", grid4); + + // // heat map shows from which locations you're heard the best + // this.hmLayer.getSource().addFeature(feature); + // } + + // cache data about spots + for (const spot of spotList) + { + this.dt__data.set(spot.GetDTLocal(), { + spot: spot, + }); + } + + // add lines + if (spotList.length > 1 && this.showLines) + { + // get latLngList from spots + let latLngList = []; + for (const spot of spotList) + { + latLngList.push([spot.GetLat(), spot.GetLng()]); + } + + // do special processing to draw lines, which avoids the 180/-180 boundary issue + let lineSegmentList = this.MakeLineSegmentList(latLngList); + + // plot it + for (const { lineString, spotIndexA, spotIndexB } of lineSegmentList) + { + // turn into a line + let feature = new ol.Feature({ + geometry: lineString, + }); + + feature.set("spotIndexA", spotIndexA); + feature.set("spotIndexB", spotIndexB); + feature.setStyle(this.GetLineStyle(1.0)); + + this.spotLayer.getSource().addFeature(feature); + this.lineFeatureList.push(feature); + if (this.lineFeatureListByEndIdx[spotIndexB] == undefined) + { + this.lineFeatureListByEndIdx[spotIndexB] = []; + } + this.lineFeatureListByEndIdx[spotIndexB].push(feature); + this.lineOpacityByEndIdx[spotIndexB] = 1.0; + } + } + + this.ApplyHoverEmphasis(); + + // keep the map load from being so sudden + Animation.FadeOpacityUp(this.ui); + + if (spotList.length == 0) + { + // do nothing, prior spots cleared, we're just a blank map now + } + else if (this.dataSetPreviously == true) + { + if (!preserveView) + { + // leave center and zoom as it was previously + let spotLatest = spotList.at(-1); + + if (this.autoMove) + { + // smoothly pan to the new location + let view = this.map.getView(); + view.animate({ + center: spotLatest.GetLoc(), + duration: 500, + }); + } + } + } + else + { + // center map on latest + let spotLatest = spotList.at(-1); + this.map.getView().setCenter(spotLatest.GetLoc()); + this.map.getView().setZoom(4); + } + + this.dataSetPreviously = true; + + this.spotListLast = spotList; + + // Always ring the latest spot with a red circle. + this.UnHighlightLatest(); + this.HighlightLatest(); + + this.t.Event("SpotMap::SetSpotList End"); + } + + FocusOn(ts) + { + // hopefully find the spot based on time right away + let data = this.dt__data.get(ts); + let spot = null; + + // console.log(`FocusOn ${ts}`) + + if (data) + { + // console.log(`found immediately`) + spot = data.spot; + } + else + { + // console.log(`hunting for it`) + // we don't have that time, find the spot that is closest in time + let tsDiffMin = null; + for (let [keyTs, valueData] of this.dt__data) + { + let spotTmp = valueData.spot; + let tsDiff = Math.abs(utl.MsDiff(keyTs, ts)); + + // console.log(`${keyTs} - ${ts} = ${tsDiff}`) + + if (tsDiffMin == null || tsDiff < tsDiffMin) + { + tsDiffMin = tsDiff; + + // console.log(`new spot`) + + spot = spotTmp; + } + } + + // overwrite the time now that we have a specific spot to focus on + if (tsDiffMin) + { + ts = spot.GetDTLocal(); + } + } + + // work out where on the screen this spot is + let pixel = this.map.getPixelFromCoordinate(spot.GetLoc()); + + // if it is out of the screen, things don't seem to work correctly, + // so zoom out so much that everything is on the screen + let [pixX, pixY] = pixel; + let [mapWidth, mapHeight] = this.map.getSize(); + if (pixX < 0 || pixX > mapWidth || pixY < 0 || pixY > mapHeight) + { + // console.log(`have to move the screen`) + this.map.getView().setCenter(spot.GetLoc()); + this.map.getView().setZoom(1); + } + + // async complete the rest after the map has a chance to do stuff for + // potentially zooming out + setTimeout(() => { + let pixel = this.map.getPixelFromCoordinate(spot.GetLoc()); + + // now that we can see the feature, we use a pixel to point, but now + // need to figure out which specific feature is the one we're + // looking for, since many can be "at" the same pixel + let f = null; + let tsDiffMin = null; + this.map.forEachFeatureAtPixel(pixel, (feature, layer) => { + let fSpot = feature.get("spot"); + if (fSpot) + { + let tsDiff = Math.abs(utl.MsDiff(ts, fSpot.GetDTLocal())); + + // console.log(`${ts} - ${fSpot.GetDTLocal()} = ${tsDiff}`) + + if (tsDiffMin == null || tsDiff < tsDiffMin) + { + tsDiffMin = tsDiff; + + // console.log(`new feature`) + f = feature; + } + } + }); + + // console.log(`done looking at features`) + + let coordinate = null; + if (f) + { + let g = f.getGeometry(); + let c = g.getCoordinates(); + coordinate = c; + } + + this.map.dispatchEvent({ + type: 'click', + pixel: pixel, + pixel_: pixel, + dragging: false, + coordinate: coordinate, + coordinate_: coordinate, + originalEvent: {}, + dragging: false, + map: this.map, + spot: spot, + }); + }, 50); + } + + HighlightLatest() + { + this.focusFeature = null; + + if (this.spotListLast.length != 0) + { + let spot = this.spotListLast[this.spotListLast.length - 1]; + + let style = new ol.style.Style({ + image: new ol.style.Circle({ + radius: 8, + fill: new ol.style.Fill({ + color: 'rgba(255, 255, 255, 0.1)', + }), + stroke: new ol.style.Stroke({ + color: 'rgba(255, 0, 0, 1)', + width: 2.0, + }), + }), + }); + + let point = new ol.geom.Point(spot.GetLoc()); + let feature = new ol.Feature({ + geometry: point, + }); + feature.setStyle(style); + feature.set("type", "spot"); + feature.set("spot", spot); + this.spotLayer.getSource().addFeature(feature); + + this.focusFeature = feature; + } + } + + UnHighlightLatest() + { + if (this.focusFeature) + { + this.spotLayer.getSource().removeFeature(this.focusFeature); + + this.focusFeature = null; + } + } + + MakeLineSegmentList(latLngList) + { + let lineSegmentList = []; + + function CloseToWrap(lng) + { + return (180 - Math.abs(lng)) < 30; + } + + function ToEzLat(lat) + { + return (lat < 0) ? (90 + -lat) : lat; + } + + function FromEzLat(latEz) + { + return (latEz > 90) ? (-latEz + 90) : latEz; + } + + function CalcCrossingLat(latA, lngA, latB, lngB) + { + let latAEz = ToEzLat(latA); + let latBEz = ToEzLat(latB); + let latCrossEz = latAEz; + + if (lngA > 0 && lngB < 0) + { + let lngToMark = 180 - lngA; + let markToLng = lngB - -180; + let dx = lngToMark + markToLng; + let dy = Math.abs(latBEz - latAEz); + + if (dx == 0) + { + latCrossEz = latAEz; + } + else if (latAEz < latBEz) + { + let dc = Math.sqrt((dx ** 2) + (dy ** 2)); + let a = lngToMark; + let c = lngToMark / dx * dc; + let b = Math.sqrt(Math.max(0, (c ** 2) - (a ** 2))); + latCrossEz = latAEz + b; + } + else if (latAEz > latBEz) + { + let dc = Math.sqrt((dx ** 2) + (dy ** 2)); + let a = lngToMark; + let c = lngToMark / dx * dc; + let b = Math.sqrt(Math.max(0, (c ** 2) - (a ** 2))); + latCrossEz = latAEz - b; + } + } + else if (lngA < 0 && lngB > 0) + { + let lngToMark = 180 - lngB; + let markToLng = lngA - -180; + let dx = lngToMark + markToLng; + let dy = Math.abs(latBEz - latAEz); + + if (dx == 0) + { + latCrossEz = latAEz; + } + else if (latAEz < latBEz) + { + let dc = Math.sqrt((dx ** 2) + (dy ** 2)); + let a = lngToMark; + let c = lngToMark / dx * dc; + let b = Math.sqrt(Math.max(0, (c ** 2) - (a ** 2))); + latCrossEz = latAEz + b; + } + else if (latAEz > latBEz) + { + let dc = Math.sqrt((dx ** 2) + (dy ** 2)); + let a = lngToMark; + let c = lngToMark / dx * dc; + let b = Math.sqrt(Math.max(0, (c ** 2) - (a ** 2))); + latCrossEz = latAEz - b; + } + } + + return FromEzLat(latCrossEz); + } + + for (let i = 1; i < latLngList.length; ++i) + { + let [latA, lngA] = latLngList[i - 1]; + let [latB, lngB] = latLngList[i]; + + let crossesPosToNeg = lngA > 0 && lngB < 0 && (CloseToWrap(lngA) || CloseToWrap(lngB)); + let crossesNegToPos = lngA < 0 && lngB > 0 && (CloseToWrap(lngA) || CloseToWrap(lngB)); + + if (crossesPosToNeg || crossesNegToPos) + { + let latCross = CalcCrossingLat(latA, lngA, latB, lngB); + + let breakA = crossesPosToNeg ? [180, latCross] : [-180, latCross]; + let breakB = crossesPosToNeg ? [-180, latCross] : [180, latCross]; + + lineSegmentList.push({ + lineString: new ol.geom.LineString([ + ol.proj.fromLonLat([lngA, latA]), + ol.proj.fromLonLat(breakA), + ]), + spotIndexA: i - 1, + spotIndexB: i, + }); + lineSegmentList.push({ + lineString: new ol.geom.LineString([ + ol.proj.fromLonLat(breakB), + ol.proj.fromLonLat([lngB, latB]), + ]), + spotIndexA: i - 1, + spotIndexB: i, + }); + } + else + { + lineSegmentList.push({ + lineString: new ol.geom.LineString([ + ol.proj.fromLonLat([lngA, latA]), + ol.proj.fromLonLat([lngB, latB]), + ]), + spotIndexA: i - 1, + spotIndexB: i, + }); + } + } + + return lineSegmentList; + } + +// lng( 179.5846), lat(40.7089) => lng(19991266.226313718), lat(4969498.835332252) +// lng(-176.8324), lat(41.7089) => lng(-19684892.723752473), lat(5117473.325588154) + MakeLineStringList(latLngList) + { + let locListList = [[]]; + + function CloseToWrap(lng) + { + // if you're within x degrees of the wraparound, let's assume + // this is the case we're dealing with (not the wrap over europe) + return (180 - Math.abs(lng)) < 30; + } + + let latLast; + let lngLast; + for (let i = 0; i < latLngList.length; ++i) + { + let [lat, lng] = latLngList[i]; + + // only check subsequent points to see if they cross the 180/-180 longitude + if (i) + { + if (lngLast > 0 && lng < 0 && (CloseToWrap(lngLast) || CloseToWrap(lng))) + { + // oops, it happened going from +180 to -180 + + // let's convert latitude to easier to math numbers + // latitude is 90 at the poles, converges to zero at the + // equator. + // the south is depicted as having a negative latitude. + // so let's call it 0 (north pole) to 180 (south pole) + + let latEz = (lat < 0) ? (90 + -lat) : lat; + let latLastEz = (latLast < 0) ? (90 + -latLast) : latLast; + + // want to determine crossover point + let latCrossoverEz; + + // let's look at whether travel toward north or south + if (latLastEz < latEz) + { + // example: 20m, chan 65, VE3OCL, 2023-04-25 to 2023-04-26 + + // moving north, interpolate + // let's model a giant triangle from last pos to this pos + + // measure horizontal distance + let lngToMark = 180 - lngLast; + let markToLng = lng - -180; + let dx = lngToMark + markToLng; + + // measure vertical distance + let dy = latEz - latLastEz; + + // calculate big triangle hypotenuse + let dc = Math.sqrt((dx ** 2) + (dy ** 2)); + + // now we can calculate the portion of the big triangle + // that the meridian slices off, which itself is a triangle + // on the left side. + + // horizontal distance is lngToMark + let a = lngToMark; + + // the small hypotenuse is the same percent of its length + // as the length to the mark is of the entire distance + let c = lngToMark / dx * dc; + + // now reverse the Pythagorean theorem + let b = Math.sqrt(Math.sqrt(c) - Math.sqrt(a)); + + // ok that's our crossover point + latCrossoverEz = latLastEz + b; + } + else if (latLastEz == latEz) + { + // you know the lat + latCrossoverEz = latEz; + } + else if (latLastEz > latEz) + { + // example: 20m, chan 99, VE3KCL, 2023-04-30 to 2023-04-31 + + // moving south, interpolate + // let's model a giant triangle from last pos to this pos + + // measure horizontal distance + let lngToMark = 180 - lngLast; + let markToLng = lng - -180; + let dx = lngToMark + markToLng; + + // measure vertical distance + let dy = latLastEz - latEz; + + // calculate big triangle hypotenuse + let dc = Math.sqrt((dx ** 2) + (dy ** 2)); + + // now we can calculate the portion of the big triangle + // that the meridian slices off, which itself is a triangle + // on the left side. + + // horizontal distance is lngToMark + let a = lngToMark; + + // the small hypotenuse is the same percent of its length + // as the length to the mark is of the entire distance + let c = lngToMark / dx * dc; + + // now reverse the Pythagorean theorem + let b = Math.sqrt(Math.sqrt(c) - Math.sqrt(a)); + + // ok that's our crossover point + latCrossoverEz = latLastEz - b; + } + + // convert ez back to real latitude + let latCrossover = (latCrossoverEz > 90) ? (-latCrossoverEz + 90) : latCrossoverEz; + + // now break this point into two, one which gets from the prior + // point to the break, and then from the break to this point. + // put in the right (opposite) order for conversion + let one = [180, latCrossover]; + let two = [-180, latCrossover]; + let three = [lng, lat]; + + locListList.at(-1).push(ol.proj.fromLonLat(one)); + locListList.push([]); + locListList.at(-1).push(ol.proj.fromLonLat(two)); + locListList.at(-1).push(ol.proj.fromLonLat(three)); + } + else if (lngLast < 0 && lng > 0 && (CloseToWrap(lngLast) || CloseToWrap(lng))) + { + // oops, it happened going from -180 to +180 + + // let's convert latitude to easier to math numbers + // latitude is 90 at the poles, converges to zero at the + // equator. + // the south is depicted as having a negative latitude. + // so let's call it 0 (north pole) to 180 (south pole) + + let latEz = (lat < 0) ? (90 + -lat) : lat; + let latLastEz = (latLast < 0) ? (90 + -latLast) : latLast; + + // want to determine crossover point + let latCrossoverEz; + + // let's look at whether travel toward north or south + if (latLastEz < latEz) + { + // example: 20m, chan 99, VE3CKL, 2023-03-12 to 2023-03-12 + + // moving north, interpolate + // let's model a giant triangle from last pos to this pos + + // measure horizontal distance + let lngToMark = 180 - lng; + let markToLng = lngLast - -180; + let dx = lngToMark + markToLng; + + // measure vertical distance + let dy = latEz - latLastEz; + + // calculate big triangle hypotenuse + let dc = Math.sqrt((dx ** 2) + (dy ** 2)); + + // now we can calculate the portion of the big triangle + // that the meridian slices off, which itself is a triangle + // on the left side. + + // horizontal distance is lngToMark + let a = lngToMark; + + // the small hypotenuse is the same percent of its length + // as the length to the mark is of the entire distance + let c = lngToMark / dx * dc; + + // now reverse the Pythagorean theorem + let b = Math.sqrt(Math.sqrt(c) - Math.sqrt(a)); + + // ok that's our crossover point + latCrossoverEz = latLastEz + b; + } + else if (latLastEz == latEz) + { + // you know the lat + latCrossoverEz = latEz; + } + else if (latLastEz > latEz) + { + // example: ?? + + // moving south, interpolate + // let's model a giant triangle from last pos to this pos + + // measure horizontal distance + let lngToMark = 180 - lng; + let markToLng = lngLast - -180; + let dx = lngToMark + markToLng; + + // measure vertical distance + let dy = latLastEz - latEz; + + // calculate big triangle hypotenuse + let dc = Math.sqrt((dx ** 2) + (dy ** 2)); + + // now we can calculate the portion of the big triangle + // that the meridian slices off, which itself is a triangle + // on the left side. + + // horizontal distance is lngToMark + let a = lngToMark; + + // the small hypotenuse is the same percent of its length + // as the length to the mark is of the entire distance + let c = lngToMark / dx * dc; + + // now reverse the Pythagorean theorem + let b = Math.sqrt(Math.sqrt(c) - Math.sqrt(a)); + + // ok that's our crossover point + latCrossoverEz = latLastEz - b; + } + + // convert ez back to real latitude + let latCrossover = (latCrossoverEz > 90) ? (-latCrossoverEz + 90) : latCrossoverEz; + + // now break this point into two, one which gets from the prior + // point to the break, and then from the break to this point. + // put in the right (opposite) order for conversion + let one = [-180, latCrossover]; + let two = [180, latCrossover]; + let three = [lng, lat]; + + locListList.at(-1).push(ol.proj.fromLonLat(one)); + locListList.push([]); + locListList.at(-1).push(ol.proj.fromLonLat(two)); + locListList.at(-1).push(ol.proj.fromLonLat(three)); + } + else + { + locListList.at(-1).push(ol.proj.fromLonLat([lng, lat])); + } + } + else + { + locListList.at(-1).push(ol.proj.fromLonLat([lng, lat])); + } + + latLast = lat; + lngLast = lng; + } + + // convert locListList to LineStringList + let lineStringList = []; + for (let locList of locListList) + { + lineStringList.push(new ol.geom.LineString(locList)); + } + + return lineStringList + } + + + +} + + + + + + diff --git a/js/SpotMapAsyncLoader.js b/js/SpotMapAsyncLoader.js new file mode 100644 index 0000000..0dab0df --- /dev/null +++ b/js/SpotMapAsyncLoader.js @@ -0,0 +1,49 @@ +/* +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.) +*/ + + + +// loads the spot map module +// lets people get an event when that happens + +// this is necessary because I want SpotMap +// - to know its own resources +// - be loadable as-is, and work via normal import {} from ... +// - which is synchronous + +// this module solves the problem of +// - wanting SpotMap to start loading as soon as humanly possible +// on page load, by getting kicked off as early as construction +// of objects, etc, not waiting for query results, or something +// - keeping an easily re-usable bit of code that doesn't require +// boilerplate anywhere a map might want to get used + + +// map class relies on external libraries to load, so we want to do the work of loading +// asynchronously and immediately as soon as the library is imported. +let mapLoadPromise = import('./SpotMap.js'); +let module = null; + +// be the first to register for result, which is the loaded module +mapLoadPromise.then((result) => { + module = result; +}) + + +export class SpotMapAsyncLoader +{ + static async SetOnLoadCallback(fnOnLoad) + { + // any other caller will use this function, which will only fire after + // our registered-first 'then', so we know the spot map will be loaded. + mapLoadPromise.then(() => { + fnOnLoad(module); + }); + } +} + diff --git a/js/TabularData.js b/js/TabularData.js new file mode 100644 index 0000000..99868d5 --- /dev/null +++ b/js/TabularData.js @@ -0,0 +1,679 @@ +/* +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.) +*/ + + +export class TabularData +{ + constructor(dataTable) + { + this.dataTable = dataTable; + + this.col__idx = new Map(); + + this.col__metaData = new Map(); + this.row__metaData = new WeakMap(); + + this.#CacheHeaderLocations(); + } + + // create a new set of rows with copies of the values + // duplicate the metadata. + // metadata row keys will be new (objects), values will be copied + // metadata col keys will be copied (strings), values will be copied + Clone() + { + // prepare new objects + let dataTableNew = []; + let tdNew = new TabularData(dataTableNew); + + // make new rows, with copies of data (including header) + for (let rowCur of this.dataTable) + { + let rowNew = [... rowCur]; + + dataTableNew.push(rowNew); + + // copy any row meta data if any + if (this.row__metaData.has(rowCur)) + { + tdNew.row__metaData.set(rowNew, this.row__metaData.get(rowCur)); + } + } + + // col meta data by big copy, keys are strings, so ok to do + // without tying to some object + tdNew.col__metaData = new Map(this.col__metaData); + + // update internal data structure + tdNew.#CacheHeaderLocations(); + + return tdNew; + } + + // will only set the col metadata if it's a real column. + // this data is destroyed if the column is destroyed. + SetColMetaData(col, metaData) + { + let idx = this.Idx(col); + if (idx != undefined) + { + this.col__metaData.set(col, metaData); + } + } + + // for valid columns, return metadata, creating if needed. + // for invalid columns, undefined + GetColMetaData(col) + { + let retVal = undefined; + + let idx = this.Idx(col); + if (idx != undefined) + { + if (this.col__metaData.has(col) == false) + { + this.col__metaData.set(col, {}); + } + + retVal = this.col__metaData.get(col); + } + + return retVal; + } + + // will set the row metadata if an object or idx in range, + // discard if numerically out of range. + // all row metadata survives rows being moved around. + // this data is destroyed if the row is destroyed. + SetRowMetaData(row, metaData) + { + row = this.#GetRow(row); + if (row != undefined) + { + this.row__metaData.set(row, metaData); + } + } + + // will get the row metadata if an object or idx in range, creating if needed, + // undefined if numerically out of range. + GetRowMetaData(row) + { + let retVal = undefined; + + row = this.#GetRow(row); + if (row != undefined) + { + if (this.row__metaData.has(row) == false) + { + this.row__metaData.set(row, {}); + } + + retVal = this.row__metaData.get(row); + } + + return retVal; + } + + GetDataTable() + { + return this.dataTable; + } + + GetHeaderList() + { + let retVal = []; + + if (this.dataTable.length) + { + // prevent caller from modifying column names directly + retVal = [... this.dataTable[0]]; + } + + return retVal; + } + + GetColCount() + { + return this.GetHeaderList().length; + } + + // return the number of data rows + Length() + { + let retVal = 0; + + if (this.dataTable.length) + { + retVal = this.dataTable.length - 1; + } + + return retVal; + } + + #CacheHeaderLocations() + { + if (this.dataTable && this.dataTable.length) + { + this.col__idx = new Map(); + + const headerRow = this.dataTable[0]; + + for (let i = 0; i < headerRow.length; ++i) + { + const col = headerRow[i]; + + this.col__idx.set(col, i); + } + } + } + + Idx(col) + { + // undefined if no present + return this.col__idx.get(col); + } + + // if given a row (array) object, return that object. + // if given a numeric index, return the row in the table at that logical index. + #GetRow(row) + { + if (row == undefined || row == null) { return undefined; } + + let retVal = undefined; + + if (typeof row == "object") + { + retVal = row; + } + else + { + if (row + 1 < this.dataTable.length) + { + retVal = this.dataTable[row + 1]; + } + } + + return retVal; + } + + // if given a row (array) object, return the value in the specified column. + // if given a numeric index, return the value in the specified column. + Get(row, col) + { + let retVal = undefined; + + row = this.#GetRow(row); + if (row) + { + retVal = row[this.Idx(col)]; + } + + return retVal; + } + + // if given a row (array) object, return the value in the specified column. + // if given a numeric index, return the value in the specified column. + Set(row, col, val) + { + if (typeof row == "object") + { + row[this.Idx(col)] = val; + } + else + { + this.dataTable[row + 1][this.Idx(col)] = val; + } + } + + // idx of data, not including header + DeleteRowList(idxList) + { + // put in descending order so we don't need to recalculate indices after each delete + idxList.sort((a, b) => (a - b)); + idxList.reverse(); + + for (let idx of idxList) + { + this.DeleteRow(idx); + } + } + + // idx of data, not including header + DeleteRow(idx) + { + this.dataTable.splice(idx + 1, 1); + } + + // create a new row, with empty values. + // row will have the same number of elements as the header. + // the row is returned to the caller and is appropriate for use with + // the Get() and Set() API. + AddRow() + { + let row = new Array(this.GetColCount()); + + this.dataTable.push(row); + + return row; + } + + RenameColumn(colOld, colNew) + { + this.dataTable[0][this.Idx(colOld)] = colNew; + + this.#CacheHeaderLocations(); + + if (colOld != colNew) + { + this.col__metaData.set(colNew, this.col__metaData.get(colOld)); + this.col__metaData.delete(colOld); + } + } + + DeleteColumn(col) + { + let idx = this.Idx(col); + + if (idx != undefined) + { + for (let row of this.dataTable) + { + row.splice(idx, 1); + } + } + + this.#CacheHeaderLocations(); + + this.col__metaData.delete(col); + } + + DeleteColumnList(colList) + { + for (let col of colList) + { + this.DeleteColumn(col); + } + } + + DeleteEmptyColumns() + { + let colList = []; + + for (let i = 0; i < this.dataTable[0].length; ++i) + { + let col = this.dataTable[0][i]; + + let allBlank = true; + + for (let j = 1; j < this.dataTable.length; ++j) + { + let val = this.dataTable[j][i]; + + if (val != "" && val != null) + { + allBlank = false; + } + } + + if (allBlank) + { + colList.push(col); + } + } + + this.DeleteColumnList(colList); + } + + MakeDataTableFromRowList(rowList) + { + let dataTable = [[... this.dataTable[0]]]; + + for (let row of rowList) + { + dataTable.push([... row]); + } + + return dataTable; + } + + MakeDataTableFromRow(row) + { + return this.MakeDataTableFromRowList([row]); + } + + Extract(headerList) + { + const headerRow = this.dataTable[0]; + let idxList = []; + + for (const header of headerList) + { + let idx = headerRow.indexOf(header); + + idxList.push(idx); + } + + // build new data table + let dataTableNew = []; + for (const row of this.dataTable) + { + let rowNew = []; + + for (const idx of idxList) + { + rowNew.push(row[idx]); + } + + dataTableNew.push(rowNew); + } + + return dataTableNew; + } + + ExtractDataOnly(headerList) + { + let dataTable = this.Extract(headerList); + + return dataTable.slice(1); + } + + DeepCopy() + { + return this.Extract(this.dataTable[0]); + } + + ForEach(fnEachRow, reverseOrder) + { + if (reverseOrder == undefined) { reverseOrder = false; } + + if (reverseOrder == false) + { + for (let i = 1; i < this.dataTable.length; ++i) + { + let row = this.dataTable[i]; + + fnEachRow(row, i - 1); + } + } + else + { + for (let i = this.dataTable.length - 1; i >= 1; --i) + { + let row = this.dataTable[i]; + + fnEachRow(row, i - 1); + } + } + } + + AppendGeneratedColumns(colHeaderList, fnEachRow, reverseOrder) + { + if (reverseOrder == undefined) { reverseOrder = false; } + + this.dataTable[0].push(... colHeaderList); + + if (reverseOrder == false) + { + for (let i = 1; i < this.dataTable.length; ++i) + { + let row = this.dataTable[i]; + + row.push(... fnEachRow(row)); + } + } + else + { + for (let i = this.dataTable.length - 1; i >= 1; --i) + { + let row = this.dataTable[i]; + + row.push(... fnEachRow(row)); + } + } + + this.#CacheHeaderLocations(); + } + + PrependGeneratedColumns(colHeaderList, fnEachRow, reverseOrder) + { + if (reverseOrder == undefined) { reverseOrder = false; } + + this.dataTable[0].unshift(... colHeaderList); + + if (reverseOrder == false) + { + for (let i = 1; i < this.dataTable.length; ++i) + { + let row = this.dataTable[i]; + + row.unshift(... fnEachRow(row)); + } + } + else + { + for (let i = this.dataTable.length - 1; i >= 1; --i) + { + let row = this.dataTable[i]; + + row.unshift(... fnEachRow(row)); + } + } + + this.#CacheHeaderLocations(); + } + + GenerateModifiedColumn(colHeaderList, fnEachRow, reverseOrder) + { + if (reverseOrder == undefined) { reverseOrder = false; } + + let col = colHeaderList[0]; + let colIdx = this.Idx(col); + + if (colIdx == undefined) + { + return; + } + + let rowList = []; + + if (reverseOrder == false) + { + // build new values + for (let i = 1; i < this.dataTable.length; ++i) + { + let row = this.dataTable[i]; + + let rowNew = fnEachRow(row, i - 1); + + rowList.push(rowNew[0]); + } + + // update table + for (let i = 1; i < this.dataTable.length; ++i) + { + let row = this.dataTable[i]; + + row[colIdx] = rowList[i - 1]; + } + } + else + { + for (let i = this.dataTable.length - 1; i >= 1; --i) + { + let row = this.dataTable[i]; + + let rowNew = fnEachRow(row, i - 1); + + // row[this.Idx(col)] = rowNew[0]; + rowList.push(rowNew[0]); + } + + for (let i = this.dataTable.length - 1; i >= 1; --i) + { + let row = this.dataTable[i]; + + row[colIdx] = rowList[rowList.length - i]; + } + } + } + + // rearranges columns, leaving row objects intact (in-place operation). + // specified columns which don't exist will start to exist, and undefined values + // will be present in the cells. + SetColumnOrder(colList) + { + let colListNewSet = new Set(colList); + let colListOldSet = new Set(this.GetHeaderList()); + + // figure out which old columns are no longer present + let colListDelSet = colListOldSet.difference(colListNewSet); + + // modify each row, in place, to have only the column values + for (let i = 1; i < this.dataTable.length; ++i) + { + let row = this.#GetRow(i - 1); + + // build a new row of values. + // undefined for any invalid columns. + let rowNew = []; + for (let col of colList) + { + rowNew.push(this.Get(row, col)); + } + + // wipe out contents of existing row, but keep row object + row.length = 0; + + // add new values into the row, in order + row.push(... rowNew); + } + + // update headers in place + this.dataTable[0].length = 0; + this.dataTable[0].push(... colList); + + // delete metadata from destroyed columns + for (let col of colList) + { + this.col__metaData.delete(col); + } + + // update column index + this.#CacheHeaderLocations(); + } + + // Will put specified columns in the front, in this order, if they exist. + // Columns not specified will retain their order. + PrioritizeColumnOrder(colHeaderList) + { + // get reference of existing columns + let remainingColSet = new Set(this.GetHeaderList()); + + // get list of existing priority columns + let priorityColSet = new Set(); + for (let col of colHeaderList) + { + if (remainingColSet.has(col)) + { + remainingColSet.delete(col); + priorityColSet.add(col); + } + } + + // now we have two lists of columns: + // - priorityColSet - existing columns in the order specified + // - remainingColSet - every other existing column other than priority column set, + // in original order + + // now arrange columns + let colHeaderListNew = [... priorityColSet.values(), ... remainingColSet.values()]; + + this.SetColumnOrder(colHeaderListNew); + } + + FillUp(col, defaultVal) + { + defaultVal = defaultVal | ""; + + let idx = this.Idx(col); + + for (let i = this.dataTable.length - 1; i >= 1; --i) + { + const row = this.dataTable[i]; + + let val = row[idx]; + + if (val == null) + { + if (i == this.dataTable.length - 1) + { + val = defaultVal; + } + else + { + val = this.dataTable[i + 1][idx]; + } + + row[idx] = val; + } + } + } + + FillDown(col, defaultVal, reverseOrder) + { + defaultVal = defaultVal | ""; + + let idx = this.Idx(col); + + for (let i = 1; i < this.dataTable.length; ++i) + { + const row = this.dataTable[i]; + + let val = row[idx]; + + if (val == null) + { + if (i == 1) + { + val = defaultVal; + } + else + { + val = this.dataTable[i - 1][idx]; + } + + row[idx] = val; + } + } + } + + GetDataForCol(col) + { + let valList = []; + + if (this.dataTable && this.dataTable.length && this.Idx(col) != undefined) + { + for (let i = 0; i < this.Length(); ++i) + { + valList.push(this.Get(i, col)); + } + } + + return valList; + } + + Reverse() + { + // reverse the whole things + this.dataTable.reverse(); + + // swap the header (now at the bottom) to the top + this.dataTable.unshift(this.dataTable.pop()); + } +} + diff --git a/js/Timeline.js b/js/Timeline.js new file mode 100644 index 0000000..4aec2d6 --- /dev/null +++ b/js/Timeline.js @@ -0,0 +1,109 @@ +/* +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'; + + +export class Timeline +{ + static global = new Timeline(); + + constructor() + { + this.logOnEvent = false; + this.ccGlobal = false; + this.noCc = false; + + this.Reset(); + } + + Global() + { + return Timeline.global; + } + + SetCcGlobal(tf) + { + this.ccGlobal = tf; + } + + SetLogOnEvent(tf) + { + this.logOnEvent = tf; + } + + Reset() + { + this.eventList = []; + this.longestStr = 0; + + this.noCc = true; + this.Event(`Timeline::Reset`); + this.noCc = false; + } + + Event(name) + { + if (this.ccGlobal && this != Timeline.global && !this.noCc) + { + this.Global().Event(name); + } + + let time = performance.now(); + + this.eventList.push({ + name: name, + time: time, + }); + + if (name.length > this.longestStr) + { + this.longestStr = name.length; + } + + if (this.logOnEvent) + { + console.log(name); + } + + return time; + } + + Report(msg) + { + if (msg) + { + console.log(`Timeline report (${msg}):`); + } + else + { + console.log("Timeline report:"); + } + + // build table to output + let objList = []; + let totalMs = 0; + for (let i = 1; i < this.eventList.length; ++i) + { + totalMs += this.eventList[i - 0].time - this.eventList[i - 1].time; + + objList.push({ + from: this.eventList[i - 1].name, + to : this.eventList[i - 0].name, + diffMs: utl.Commas(Math.round(this.eventList[i - 0].time - this.eventList[i - 1].time)), + fromStartMs: utl.Commas(Math.round(totalMs)), + }); + } + + totalMs = utl.Commas(Math.round(totalMs)); + + console.table(objList); + console.log(`total ms: ${totalMs}`); + } +} + diff --git a/js/WSPR.js b/js/WSPR.js new file mode 100644 index 0000000..c3b61e5 --- /dev/null +++ b/js/WSPR.js @@ -0,0 +1,178 @@ +/* +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 './Utl.js'; + +export class WSPR +{ + static bandFreqList_ = [ + ["2190m", 136000], + ["630m", 474200], + ["160m", 1836600], + ["80m", 3568600], + ["60m", 5287200], + ["40m", 7038600], + ["30m", 10138700], + ["20m", 14095600], + ["17m", 18104600], + ["15m", 21094600], + ["12m", 24924600], + ["10m", 28124600], + ["6m", 50293000], + ["4m", 70091000], + ["2m", 144489000], + ["70cm", 432300000], + ["23cm", 1296500000], + ]; + + static GetDialFreqFromBandStr(bandStr) + { + bandStr = WSPR.GetDefaultBandIfNotValid(bandStr); + + let bandStr__dialFreq = new Map(WSPR.bandFreqList_); + let dialFreq = bandStr__dialFreq.get(bandStr); + + return dialFreq; + } + + static GetDefaultBandIfNotValid(bandStr) + { + let bandStr__dialFreq = new Map(WSPR.bandFreqList_); + + if (bandStr__dialFreq.has(bandStr) == false) + { + bandStr = "20m"; + } + + return bandStr; + } + + static GetDefaultChannelIfNotValid(channel) + { + channel = parseInt(channel); + + let retVal = 0; + + if (0 <= channel && channel <= 599) + { + retVal = channel; + } + + return retVal; + } + + // minute list, some bands are defined as rotation from 20m + static GetMinuteListForBand(band) + { + band = WSPR.GetDefaultBandIfNotValid(band); + + // get index into list (guaranteed to be found) + let idx = WSPR.bandFreqList_.findIndex(bandFreq => { + return bandFreq[0] == band; + }); + + // rotation is modded place within this list + let rotationList = [4, 2, 0, 3, 1]; + let rotation = rotationList[idx % 5]; + + let minuteList = [8, 0, 2, 4, 6]; + minuteList = utl.Rotate(minuteList, rotation); + + return minuteList; + } + + static band__channelDataMap = new Map(); + + static GetChannelDetails(bandStr, channelIn) + { + bandStr = WSPR.GetDefaultBandIfNotValid(bandStr); + channelIn = WSPR.GetDefaultChannelIfNotValid(channelIn); + + // lazy load + if (WSPR.band__channelDataMap.has(bandStr) == false) + { + let channelDataMap = new Map(); + + let id1List = ['0', '1', 'Q']; + let id3List = [`0`, `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`, `9`]; + + let dialFreq = WSPR.GetDialFreqFromBandStr(bandStr); + + let freqTxLow = dialFreq + 1500 - 100; + let freqTxHigh = dialFreq + 1500 + 100; + let freqTxWindow = freqTxHigh - freqTxLow; + + let freqBandCount = 5; + let bandSizeHz = freqTxWindow / freqBandCount; + + let freqBandList = [1, 2, 4, 5]; // skip middle band 3, but really label as 1,2,3,4 + + let minuteList = WSPR.GetMinuteListForBand(bandStr); + + let rowCount = 0; + for (const freqBand of freqBandList) + { + // figure out the frequency + let freqBandLow = (freqBand - 1) * bandSizeHz; + let freqBandHigh = freqBandLow + bandSizeHz; + let freqBandCenter = (freqBandHigh + freqBandLow) / 2; + + let rowsPerCol = freqBandCount * freqBandList.length; + + for (const minute of minuteList) + { + let freqBandLabel = freqBand; + if (freqBandLabel >= 4) { freqBandLabel = freqBandLabel - 1; } + + for (const id1 of id1List) + { + let colCount = 0; + let id1Offset = 0; + if (id1 == `1`) { id1Offset = 200; } + if (id1 == 'Q') { id1Offset = 400; } + + for (const id3 of id3List) + { + let channel = id1Offset + (colCount * rowsPerCol) + rowCount; + + channelDataMap.set(channel, { + band : bandStr, + channel: channel, + id1: id1, + id3: id3, + id13: id1 + id3, + min: minute, + lane: freqBandLabel, + freqLow: freqTxLow + freqBandLow, + freq: freqTxLow + freqBandCenter, + freqHigh: freqTxLow + freqBandHigh, + freqDial: dialFreq, + }); + + ++colCount; + } + } + + ++rowCount; + } + } + + WSPR.band__channelDataMap.set(bandStr, channelDataMap); + } + + let channelDataMap = WSPR.band__channelDataMap.get(bandStr); + let channelData = channelDataMap.get(channelIn); + + return channelData; + } +} + + + + + diff --git a/js/WSPREncoded.js b/js/WSPREncoded.js new file mode 100644 index 0000000..c7bf129 --- /dev/null +++ b/js/WSPREncoded.js @@ -0,0 +1,447 @@ +/* +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.) +*/ + + +let DEBUG = false; + +function Gather(str) +{ + if (DEBUG) + { + console.log(str); + } + + return str + "\n"; +} + + +export class WSPREncoded +{ + static EnableDebug() { DEBUG = true; } + static DisableDebug() { DEBUG = false; } + + static DBM_POWER_LIST = [ + 0, 3, 7, + 10, 13, 17, + 20, 23, 27, + 30, 33, 37, + 40, 43, 47, + 50, 53, 57, + 60 + ]; + + static EncodeNumToPower(num) + { + if (num < 0 || WSPREncoded.DBM_POWER_LIST.length - 1 < num) + { + num = 0; + } + + return WSPREncoded.DBM_POWER_LIST[num]; + } + + static DecodePowerToNum(power) + { + let powerVal = WSPREncoded.DBM_POWER_LIST.indexOf(power); + powerVal = (powerVal == -1) ? 0 : powerVal; + + return powerVal; + } + + static EncodeBase36(val) + { + let retVal; + + if (val < 10) + { + retVal = String.fromCharCode("0".charCodeAt(0) + val); + } + else + { + retVal = String.fromCharCode("A".charCodeAt(0) + (val - 10)); + } + + return retVal; + } + + static DecodeBase36(c) + { + let retVal = 0; + + let cVal = c.charCodeAt(0); + + let aVal = "A".charCodeAt(0); + let zVal = "Z".charCodeAt(0); + let zeroVal = "0".charCodeAt(0); + + if (aVal <= cVal && cVal <= zVal) + { + retVal = 10 + (cVal - aVal); + } + else + { + retVal = cVal - zeroVal; + } + + return retVal; + } + + static DecodeMaidenheadToDeg(grid, opts = {}) + { + let snap = opts.snap ?? "center"; + + grid = grid.toUpperCase(); + + let lat = 0; + let lng = 0; + + if (grid.length >= 2) + { + let g1 = grid.charAt(0); + let g2 = grid.charAt(1); + + lng += (g1.charCodeAt(0) - "A".charCodeAt(0)) * 200000; + lat += (g2.charCodeAt(0) - "A".charCodeAt(0)) * 100000; + } + + if (grid.length >= 4) + { + let g3 = grid.charAt(2); + let g4 = grid.charAt(3); + + lng += (g3.charCodeAt(0) - "0".charCodeAt(0)) * 20000; + lat += (g4.charCodeAt(0) - "0".charCodeAt(0)) * 10000; + } + else + { + if (snap == "center") + { + // snap prior decoded resolution to be in the middle of the grid + lng += 200000 / 2; + lat += 100000 / 2; + } + } + + if (grid.length >= 6) + { + let g5 = grid.charAt(4); + let g6 = grid.charAt(5); + + lng += (g5.charCodeAt(0) - "A".charCodeAt(0)) * 834; + lat += (g6.charCodeAt(0) - "A".charCodeAt(0)) * 417; + + if (snap == "center") + { + // snap this decoded resolution to be in the middle of the grid + lng += 834 / 2; + lat += 417 / 2; + } + } + else + { + if (snap == "center") + { + // snap prior decoded resolution to be in the middle of the grid + lng += 20000 / 2; + lat += 10000 / 2; + } + } + + lng -= (180 * 10000); + lat -= ( 90 * 10000); + + lng *= 100; + lat *= 100; + + lng /= 1000000; + lat /= 1000000; + + return [lat, lng]; + } + + static GetReferenceGrid4(lat, lng) + { + lat = Number(lat); + lng = Number(lng); + + if (isNaN(lat) || isNaN(lng)) + { + throw new RangeError(`Location ${lat}, ${lng} is invalid.`); + } + + if (lat < -90 || lat > 90 || lng < -180 || lng > 180) + { + throw new RangeError(`Location ${lat}, ${lng} is outside valid coordinate bounds.`); + } + + if (lat == 90) + { + lat -= Number.EPSILON; + } + + if (lng == 180) + { + lng -= Number.EPSILON; + } + + let lngFromOrigin = lng + 180; + let latFromOrigin = lat + 90; + + let fieldLngIdx = Math.floor(lngFromOrigin / 20); + let fieldLatIdx = Math.floor(latFromOrigin / 10); + + let squareLngIdx = Math.floor((lngFromOrigin % 20) / 2); + let squareLatIdx = Math.floor((latFromOrigin % 10) / 1); + + return "" + + String.fromCharCode("A".charCodeAt(0) + fieldLngIdx) + + String.fromCharCode("A".charCodeAt(0) + fieldLatIdx) + + String.fromCharCode("0".charCodeAt(0) + squareLngIdx) + + String.fromCharCode("0".charCodeAt(0) + squareLatIdx); + } + + // https://stackoverflow.com/questions/32806084/google-map-zoom-parameter-in-url-not-working + static MakeGoogleMapsLink(lat, lng) + { + // approx zoom levels + // 1: World + // 5: Landmass/continent + // 10: City + // 15: Streets + // 20: Buildings + let zoom = 4; + + return `https://maps.google.com/?q=${lat},${lng}&ll=${lat},${lng}&z=${zoom}`; + } + + static EncodeU4BCall(id1, id3, grid56, altM) + { + let retVal = ""; + + // pick apart inputs + let grid5 = grid56.substring(0, 1); + let grid6 = grid56.substring(1); + + // convert inputs into components of a big number + let grid5Val = grid5.charCodeAt(0) - "A".charCodeAt(0); + let grid6Val = grid6.charCodeAt(0) - "A".charCodeAt(0); + + let altFracM = Math.round(altM / 20); + + retVal += Gather(`grid5Val(${grid5Val}), grid6Val(${grid6Val}), altFracM(${altFracM})`); + + // convert inputs into a big number + let val = 0; + + val *= 24; val += grid5Val; + val *= 24; val += grid6Val; + val *= 1068; val += altFracM; + + retVal += Gather(`val(${val})`); + + // extract into altered dynamic base + let id6Val = val % 26; val = Math.floor(val / 26); + let id5Val = val % 26; val = Math.floor(val / 26); + let id4Val = val % 26; val = Math.floor(val / 26); + let id2Val = val % 36; val = Math.floor(val / 36); + + retVal += Gather(`id2Val(${id2Val}), id4Val(${id4Val}), id5Val(${id5Val}), id6Val(${id6Val})`); + + // convert to encoded callsign + let id2 = WSPREncoded.EncodeBase36(id2Val); + let id4 = String.fromCharCode("A".charCodeAt(0) + id4Val); + let id5 = String.fromCharCode("A".charCodeAt(0) + id5Val); + let id6 = String.fromCharCode("A".charCodeAt(0) + id6Val); + let call = id1 + id2 + id3 + id4 + id5 + id6; + + retVal += Gather(`id1(${id1}), id2(${id2}), id3(${id3}), id4(${id4}), id5(${id5}), id6(${id6})`); + retVal += Gather(`${call}`); + + retVal = call; + + return retVal; + } + + static DecodeU4BCall(call) + { + let retVal = ""; + + // break call down + let id2 = call.charAt(1); + let id4 = call.charAt(3); + let id5 = call.charAt(4); + let id6 = call.charAt(5); + + // convert to values which are offset from 'A' + let id2Val = WSPREncoded.DecodeBase36(id2); + let id4Val = id4.charCodeAt(0) - "A".charCodeAt(0); + let id5Val = id5.charCodeAt(0) - "A".charCodeAt(0); + let id6Val = id6.charCodeAt(0) - "A".charCodeAt(0); + + retVal += Gather(`id2Val(${id2Val}), id4Val(${id4Val}), id5Val(${id5Val}), id6Val(${id6Val})`); + + // integer value to use to decode + let val = 0; + + // combine values into single integer + val *= 36; val += id2Val; + val *= 26; val += id4Val; // spaces aren't used, so 26 not 27 + val *= 26; val += id5Val; // spaces aren't used, so 26 not 27 + val *= 26; val += id6Val; // spaces aren't used, so 26 not 27 + + retVal += Gather(`val ${val}`); + + // extract values + let altFracM = val % 1068; val = Math.floor(val / 1068); + let grid6Val = val % 24; val = Math.floor(val / 24); + let grid5Val = val % 24; val = Math.floor(val / 24); + + let altM = altFracM * 20; + let grid6 = String.fromCharCode(grid6Val + "A".charCodeAt(0)); + let grid5 = String.fromCharCode(grid5Val + "A".charCodeAt(0)); + let grid56 = grid5 + grid6; + + retVal += Gather(`grid ....${grid56} ; altM ${altM}`); + retVal += Gather("-----------"); + + retVal = [grid56, altM]; + + return retVal; + } + + static EncodeU4BGridPower(tempC, voltage, speedKnots, gpsValid) + { + // parse input presentations + tempC = parseFloat(tempC); + voltage = parseFloat(voltage); + speedKnots = parseFloat(speedKnots); + gpsValid = parseInt(gpsValid); + + let retVal = ""; + + // map input presentations onto input radix (numbers within their stated range of possibilities) + let tempCNum = tempC - -50; + let voltageNum = (Math.round(((voltage * 100) - 300) / 5) + 20) % 40; + let speedKnotsNum = Math.round(speedKnots / 2.0); + let gpsValidNum = gpsValid; + + retVal += Gather(`tempCNum(${tempCNum}), voltageNum(${voltageNum}), speedKnotsNum,(${speedKnotsNum}), gpsValidNum(${gpsValidNum})`); + + // shift inputs into a big number + let val = 0; + + val *= 90; val += tempCNum; + val *= 40; val += voltageNum; + val *= 42; val += speedKnotsNum; + val *= 2; val += gpsValidNum; + val *= 2; val += 1; // standard telemetry + + retVal += Gather(`val(${val})`); + + // unshift big number into output radix values + let powerVal = val % 19; val = Math.floor(val / 19); + let g4Val = val % 10; val = Math.floor(val / 10); + let g3Val = val % 10; val = Math.floor(val / 10); + let g2Val = val % 18; val = Math.floor(val / 18); + let g1Val = val % 18; val = Math.floor(val / 18); + + retVal += Gather(`grid1Val(${g1Val}), grid2Val(${g2Val}), grid3Val(${g3Val}), grid4Val(${g4Val})`); + retVal += Gather(`powerVal(${powerVal})`); + + // map output radix to presentation + let g1 = String.fromCharCode("A".charCodeAt(0) + g1Val); + let g2 = String.fromCharCode("A".charCodeAt(0) + g2Val); + let g3 = String.fromCharCode("0".charCodeAt(0) + g3Val); + let g4 = String.fromCharCode("0".charCodeAt(0) + g4Val); + let grid = g1 + g2 + g3 + g4; + let power = WSPREncoded.EncodeNumToPower(powerVal); + + retVal += Gather(`grid(${grid}), g1(${g1}), g2(${g2}), g3(${g3}), g4(${g4})`); + retVal += Gather(`power(${power})`); + + retVal += Gather(`${grid} ${power}`); + + retVal = [grid, power]; + + return retVal; + } + + static DecodeU4BGridPower(grid, power) + { + let debug = ""; + + power = parseInt(power); + + let g1 = grid.charAt(0); + let g2 = grid.charAt(1); + let g3 = grid.charAt(2); + let g4 = grid.charAt(3); + + let g1Val = g1.charCodeAt(0) - "A".charCodeAt(0); + let g2Val = g2.charCodeAt(0) - "A".charCodeAt(0); + let g3Val = g3.charCodeAt(0) - "0".charCodeAt(0); + let g4Val = g4.charCodeAt(0) - "0".charCodeAt(0); + let powerVal = WSPREncoded.DecodePowerToNum(power); + + let val = 0; + + val *= 18; val += g1Val; + val *= 18; val += g2Val; + val *= 10; val += g3Val; + val *= 10; val += g4Val; + val *= 19; val += powerVal; + + debug += Gather(`val(${val})`); + + let telemetryId = val % 2 ; val = Math.floor(val / 2); + let bit2 = val % 2 ; val = Math.floor(val / 2); + let speedKnotsNum = val % 42 ; val = Math.floor(val / 42); + let voltageNum = val % 40 ; val = Math.floor(val / 40); + let tempCNum = val % 90 ; val = Math.floor(val / 90); + + let retVal; + if (telemetryId == 0) + { + let msgType = "extra"; + let extraTelemSeq = bit2 == 0 ? "first" : "second"; + + retVal = { + msgType: "extra", + msgSeq : extraTelemSeq, + }; + } + else + { + let msgType = "standard"; + let gpsValid = bit2; + + let tempC = -50 + tempCNum; + let voltage = 3.0 + (((voltageNum + 20) % 40) * 0.05); + let speedKnots = speedKnotsNum * 2; + let speedKph = speedKnots * 1.852; + + debug += Gather(`tempCNum(${tempCNum}), tempC(${tempC})`); + debug += Gather(`voltageNum(${voltageNum}), voltage(${voltage})`); + debug += Gather(`speedKnotsNum(${speedKnotsNum}), speedKnots(${speedKnots}), speedKph(${speedKph})`); + debug += Gather(`gpsValid(${gpsValid})`); + + debug += Gather(`${tempC}, ${voltage}, ${speedKnots}, ${gpsValid}`); + + retVal = { + msgType: msgType, + data : [tempC, voltage, speedKnots, gpsValid], + }; + } + + retVal.debug = debug; + + return retVal; + } +} + + diff --git a/js/WsprCodec.js b/js/WsprCodec.js new file mode 100644 index 0000000..8746433 --- /dev/null +++ b/js/WsprCodec.js @@ -0,0 +1,1095 @@ +/* +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 { WSPREncoded } from '/js/WSPREncoded.js'; +import { StrAccumulator } from '/js/Utl.js'; + + +export class WsprCodecMaker +{ + constructor() + { + this.debug = false; + + this.codec = ""; + this.json = {}; + this.errList = []; + this.CodecClass = null; + + this.SetCodecDefFragment("MyMessageType", ""); + } + + SetDebug(debug) + { + this.debug = debug; + } + + GetFieldBitsAvailable() + { + const BITS_AVAILABLE = 29.180; + + return BITS_AVAILABLE; + } + + // allow setting just name and fields, don't worry about object structure + SetCodecDefFragment(msgName, codecFragment) + { + // support comments in input by eliminating lines with comment char before them + let codecDefLineList = []; + for (let line of codecFragment.split("\n")) + { + line = line.trim(); + + if (line.substring(0, 2) != "//") + { + codecDefLineList.push(line); + } + } + let codecFragmentUse = codecDefLineList.join("\n"); + + // attempt to parse the fragment as valid json so that decent error messages + // can be returned to users if it's busted + + this.ResetErrList(); + + let ok = true; + try + { + // expected to have a trailing comma + let fakeCodec = `{ "fieldList": [ ${codecFragmentUse}\n\n {}] }`; + + let json = JSON.parse(fakeCodec); + } + catch (e) + { + ok = false; + + this.AddErr(e); + } + + if (ok) + { + let finalFieldFragment = ` + { "name": "HdrSlot", "unit": "Enum", "lowValue": 0, "highValue": 4, "stepSize": 1 }, + { "name": "HdrType", "unit": "Enum", "lowValue": 0, "highValue": 15, "stepSize": 1 }, + { "name": "HdrRESERVED", "unit": "Enum", "lowValue": 0, "highValue": 3, "stepSize": 1 }, + { "name": "HdrTelemetryType", "unit": "Enum", "lowValue": 0, "highValue": 1, "stepSize": 1 } + `; + + // assumes the input's codeFragment ends with a comma if there are fields + let codec = ` + { + "name": "${msgName}", + "fieldList": [ ${codecFragmentUse} ${finalFieldFragment}] + }`; + ok = this.SetCodecDef(codec); + } + + return ok; + } + + SetCodecDef(codec) + { + this.codec = codec; + + let ok = this.ParseCodecDef(this.codec); + + if (ok) + { + this.Calculate(); + this.#MakeCodecClass(); + } + + return ok; + } + + GetErrList() + { + return this.errList; + } + + ResetErrList() + { + this.errList = []; + } + + AddErr(err) + { + this.errList.push(err); + + if (this.debug) + { + console.log(err); + } + } + + #IsValueSegmentList(field) + { + return Array.isArray(field.valueSegmentList); + } + + #IsSegmentedField(field) + { + return this.#IsValueSegmentList(field); + } + + #IsPositiveNumber(value) + { + return typeof value == "number" && Number.isFinite(value) && value > 0; + } + + #IsWholeNumberClose(value, epsilon = 1e-9) + { + return Math.abs(value - Math.round(value)) < epsilon; + } + + #ValidateScalarField(field) + { + let ok = true; + let stepCount = (field.highValue - field.lowValue) / field.stepSize; + + if (this.#IsWholeNumberClose(stepCount) == false) + { + ok = false; + + let err = `Field(${field.name}) stepSize(${field.stepSize}) does not evenly divide the low(${field.lowValue})-to-high(${field.highValue}) range.`; + + let factorList = []; + if (Number.isInteger(field.lowValue) && Number.isInteger(field.highValue)) + { + for (let stepSize = 1; stepSize < ((field.highValue - field.lowValue) / 2); ++stepSize) + { + let stepCountNew = (field.highValue - field.lowValue) / stepSize; + + if (Number.isInteger(stepCountNew)) + { + factorList.push(stepSize); + } + } + + if (factorList.length) + { + err += `\n`; + err += ` Whole integer steps are: ${factorList.join(", ")}.`; + } + } + + this.AddErr(err); + } + + return ok; + } + + #ValidateValueSegmentListField(field) + { + let ok = true; + + if (this.#IsValueSegmentList(field) == false || field.valueSegmentList.length == 0) + { + this.AddErr(`Field(${field.name}) valueSegmentList must be a non-empty array.`); + return false; + } + + let prevHigh = null; + for (let i = 0; i < field.valueSegmentList.length; ++i) + { + const segment = field.valueSegmentList[i]; + + if (Array.isArray(segment) == false || segment.length != 3) + { + ok = false; + this.AddErr(`Field(${field.name}) valueSegmentList[${i}] must be [low, step, high].`); + continue; + } + + const [low, step, high] = segment; + if ([low, step, high].every((value) => typeof value == "number" && Number.isFinite(value))) + { + if (low >= high) + { + ok = false; + this.AddErr(`Field(${field.name}) segment ${i + 1} low(${low}) must be less than high(${high}).`); + } + + if (step <= 0) + { + ok = false; + this.AddErr(`Field(${field.name}) segment ${i + 1} step(${step}) must be greater than zero.`); + } + + const stepCount = (high - low) / step; + if (this.#IsWholeNumberClose(stepCount) == false) + { + ok = false; + this.AddErr(`Field(${field.name}) segment ${i + 1} step(${step}) does not evenly divide the low(${low})-to-high(${high}) range.`); + } + + if (prevHigh != null && Math.abs(prevHigh - low) > 1e-9) + { + ok = false; + this.AddErr(`Field(${field.name}) segment ${i + 1} low(${low}) must equal prior segment high(${prevHigh}).`); + } + + prevHigh = high; + } + else + { + ok = false; + this.AddErr(`Field(${field.name}) valueSegmentList[${i}] values must all be finite numbers.`); + } + } + + return ok; + } + + #MakeSegmentListFromValueSegmentList(field) + { + let segmentList = []; + + for (let i = 0; i < field.valueSegmentList.length; ++i) + { + const [low, step, high] = field.valueSegmentList[i]; + const isLast = i == field.valueSegmentList.length - 1; + const rawCount = Math.round((high - low) / step); + const count = isLast ? rawCount + 1 : rawCount; + + segmentList.push({ + low, + high, + step, + isLast, + count, + }); + } + + return segmentList; + } + + #ValidateField(field) + { + let ok = true; + + const isValueSegmented = this.#IsValueSegmentList(field); + const propList = [ + "unit", + ...(isValueSegmented ? ["valueSegmentList"] : ["lowValue", "highValue", "stepSize"]), + ]; + + let IsInvalidString = (str) => { + return str.trim() == "" || str.trim() != str || str.indexOf(' ') !== -1; + }; + + let HasInvalidChar = (str) => { + return /[^a-zA-Z0-9_]/.test(str); + }; + + for (const prop of propList) + { + if (prop in field == false) + { + ok = false; + this.AddErr(`No "${prop}" property in field(${field.name})`); + } + else if (prop == "unit") + { + if (IsInvalidString(field[prop].toString())) + { + ok = false; + this.AddErr(`Field name "${prop}" cannot be blank or have whitespace`); + } + else if (HasInvalidChar(field[prop].substring(1))) + { + ok = false; + this.AddErr(`Field name "${field.name}" must only have alphanumeric characters or underscore (_) after first character`); + } + } + else if (prop == "valueSegmentList") + { + if (this.#ValidateValueSegmentListField(field) == false) + { + ok = false; + } + } + else if (prop == "stepSize") + { + let validStepSize = false; + + if (typeof field.stepSize == "number") + { + validStepSize = this.#IsPositiveNumber(field.stepSize); + } + else if (Array.isArray(field.stepSize)) + { + ok = false; + this.AddErr(`Field(${field.name}) stepSize must be a positive number.`); + continue; + } + + if (validStepSize == false) + { + ok = false; + this.AddErr(`Field(${field.name}) stepSize(${JSON.stringify(field.stepSize)}) must be a positive number`); + } + } + else if (typeof field[prop] != "number" || Number.isFinite(field[prop]) == false) + { + ok = false; + this.AddErr(`Field value "${prop}" = ${field[prop]} must be a number`); + } + } + + if (ok && this.#IsValueSegmentList(field) == false && field.lowValue >= field.highValue) + { + ok = false; + this.AddErr(`Field(${field.name}) lowValue(${field.lowValue}) must be less than highValue(${field.highValue})`); + } + + if (ok) + { + if (this.#IsValueSegmentList(field)) + { + ok = this.#ValidateValueSegmentListField(field); + } + else + { + ok = this.#ValidateScalarField(field); + } + } + + return ok; + } + + #PrepareField(field) + { + let bits = 0; + let numValues = 0; + let indexCount = 0; + let enumCount = this.#IsSegmentedField(field) + ? field.valueSegmentList.length + : 1; + let segmentList = []; + + if (this.#IsValueSegmentList(field)) + { + segmentList = this.#MakeSegmentListFromValueSegmentList(field); + numValues = segmentList.reduce((acc, segment) => acc + segment.count, 0); + indexCount = 0; + field.lowValue = segmentList[0].low; + field.highValue = segmentList[segmentList.length - 1].high; + field.stepSize = segmentList.map((segment) => segment.step); + } + else + { + let stepCount = Math.round((field.highValue - field.lowValue) / field.stepSize); + indexCount = stepCount + 1; + numValues = indexCount; + } + + if (segmentList.length) + { + let base = 0; + for (const segment of segmentList) + { + segment.base = base; + base += segment.count; + } + } + + bits = Math.log2(numValues); + + field.EnumCount = enumCount; + field.IndexCount = indexCount; + field.NumValues = numValues; + field.Bits = bits; + field.IsSegmented = this.#IsSegmentedField(field); + + if (field.IsSegmented) + { + field.SegmentList = segmentList; + } + else + { + delete field.SegmentList; + } + } + + ParseCodecDef(codec) + { + let ok = true; + + if (this.debug) + { + console.log(codec); + } + + this.ResetErrList(); + + try + { + let json = JSON.parse(codec); + + // validate basic structure + if ("name" in json == false) + { + ok = false; + this.AddErr(`No "name" property for codec`); + } + else if ("fieldList" in json == false) + { + ok = false; + this.AddErr(`No "fieldList" property for codec`); + } + else + { + let fieldNameSet = new Set(); + + let IsInvalidString = (str) => { + return str.trim() == "" || str.trim() != str || str.indexOf(' ') !== -1; + }; + + let HasInvalidChar = (str) => { + return /[^a-zA-Z0-9_]/.test(str); + }; + + for (const field of json.fieldList) + { + if ("name" in field == false) + { + ok = false; + this.AddErr(`No "name" property in field`); + } + else if (fieldNameSet.has(field.name)) + { + ok = false; + this.AddErr(`Field name "${field.name}" already defined`); + } + else if (IsInvalidString(field.name)) + { + ok = false; + this.AddErr(`Field name "${field.name}" cannot be blank or have whitespace`); + } + else if (/^[a-zA-Z]/.test(field.name) == false) + { + ok = false; + this.AddErr(`Field name "${field.name}" must start with a letter`); + } + else if (HasInvalidChar(field.name.substring(1))) + { + ok = false; + this.AddErr(`Field name "${field.name}" must only have alphanumeric characters or underscore (_) after first character`); + } + else + { + fieldNameSet.add(field.name); + if (this.#ValidateField(field) == false) + { + ok = false; + } + } + } + + if (ok) + { + this.json = json; + } + } + } + catch (e) + { + ok = false; + this.AddErr(e); + } + + return ok; + } + + GetLastErr() + { + return this.lastErr; + } + + Calculate() + { + let bitsSum = 0; + for (let field of this.json.fieldList) + { + this.#PrepareField(field); + field.BitsSum = field.Bits + bitsSum; + bitsSum += field.Bits; + } + + if (this.debug) + { + console.table(this.json.fieldList); + } + } + + GetField(fieldName) + { + let retVal = null; + + for (let field of this.json.fieldList) + { + if (field.name == fieldName) + { + retVal = field; + + break; + } + } + + return retVal; + } + + #MakeCodecClass() + { + let c = this.GenerateCodecClassDef(); + if (this.debug) + { + console.log(c); + } + + const MyClassDef = new Function('', `return ${c};`); + const MyClass = MyClassDef(); + + this.CodecClass = MyClass; + } + + GetCodecInstance() + { + let codec = new this.CodecClass(); + + codec.SetWsprEncoded(WSPREncoded); + + return codec; + } + + GenerateCodecClassDef() + { + let a = new StrAccumulator(); + + a.A(`class ${this.json.name}Codec`); + a.A(`{`); + + // Constructor + a.IncrIndent(); + a.A(`constructor()`); + a.A(`{`); + a.IncrIndent(); + a.A(`this.Reset();`); + a.DecrIndent(); + a.A(`}`); + a.DecrIndent(); + + a.A(``); + + // Application field list + a.IncrIndent(); + a.A(`GetFieldList()`); + a.A(`{`); + a.IncrIndent(); + a.A(`return [`); + a.IncrIndent(); + + let sep = ""; + + for (let field of this.json.fieldList) + { + if (field.name.substr(0, 3) != "Hdr") + { + a.A(`${sep}${JSON.stringify(field)}`); + + sep = ","; + } + } + + a.DecrIndent(); + a.A(`];`); + a.DecrIndent(); + a.A(`}`); + a.DecrIndent(); + + a.A(``); + + // Reset + a.IncrIndent(); + a.A(`Reset()`); + a.A(`{`); + a.IncrIndent(); + a.A(`this.call = "0A0AAA";`); + a.A(`this.grid = "AA00";`); + a.A(`this.powerDbm = 0;`); + a.A(``); + a.A(`this.id13 = "00";`); + a.A(``); + a.A(`if (this.wsprEncoded == undefined) { this.wsprEncoded = null; }`); + a.A(``); + for (let field of this.json.fieldList) + { + a.A(`this.${field.name} = ${field.lowValue};`); + } + a.DecrIndent(); + a.A(`}`); + a.DecrIndent(); + + a.A(` `); + + // Hack to get WSPREncoded into this object + a.IncrIndent(); + a.A(`SetWsprEncoded(wsprEncoded)`); + a.A(`{`); + a.IncrIndent(); + a.A(`this.wsprEncoded = wsprEncoded;`); + a.DecrIndent(); + a.A(`}`); + a.DecrIndent(); + + a.A(` `); + + // Set id13 + a.IncrIndent(); + a.A(`SetId13(id13)`); + a.A(`{`); + a.IncrIndent(); + a.A(`this.id13 = id13;`); + a.DecrIndent(); + a.A(`}`); + a.DecrIndent(); + + a.A(` `); + + // Get id13 + a.IncrIndent(); + a.A(`GetId13(id13)`); + a.A(`{`); + a.IncrIndent(); + a.A(`return this.id13;`); + a.DecrIndent(); + a.A(`}`); + a.DecrIndent(); + + // Setters / Getters + for (let field of this.json.fieldList) + { + a.A(` `); + + // Setter + a.IncrIndent(); + a.A(`Set${field.name}${field.unit}(inputVal)`); + a.A(`{`); + a.IncrIndent(); + + a.A(`let val = inputVal ?? ${field.lowValue};`); + a.A(``); + a.A(`if (val < ${field.lowValue}) { val = ${field.lowValue}; }`); + a.A(`else if (val > ${field.highValue}) { val = ${field.highValue}; }`); + a.A(``); + a.A(`this.${field.name} = this.Get${field.name}${field.unit}ValueFromNumber(this.Get${field.name}${field.unit}NumberFromValue(val));`); + + a.DecrIndent(); + a.A(`}`); + a.DecrIndent(); + + a.A(` `); + + // Getter + a.IncrIndent(); + a.A(`Get${field.name}${field.unit}()`); + a.A(`{`); + a.IncrIndent(); + + a.A(`return this.${field.name};`); + + a.DecrIndent(); + a.A(`}`); + a.DecrIndent(); + + a.A(` `); + + // field metadata - low value + a.IncrIndent(); + a.A(`Get${field.name}${field.unit}LowValue()`); + a.A(`{`); + a.IncrIndent(); + + a.A(`return ${field.lowValue};`); + + a.DecrIndent(); + a.A(`}`); + a.DecrIndent(); + + a.A(` `); + + // field metadata - high value + a.IncrIndent(); + a.A(`Get${field.name}${field.unit}HighValue()`); + a.A(`{`); + a.IncrIndent(); + + a.A(`return ${field.highValue};`); + + a.DecrIndent(); + a.A(`}`); + a.DecrIndent(); + + a.A(` `); + + // field metadata - step size + a.IncrIndent(); + a.A(`Get${field.name}${field.unit}StepSize()`); + a.A(`{`); + a.IncrIndent(); + + a.A(`return ${JSON.stringify(field.stepSize)};`); + + a.DecrIndent(); + a.A(`}`); + a.DecrIndent(); + + a.A(` `); + + // Encoded Number Getter + a.IncrIndent(); + a.A(`Get${field.name}${field.unit}NumberFromValue(inputVal)`); + a.A(`{`); + a.IncrIndent(); + + a.A(`let val = inputVal ?? ${field.lowValue};`); + a.A(`if (val < ${field.lowValue}) { val = ${field.lowValue}; }`); + a.A(`else if (val > ${field.highValue}) { val = ${field.highValue}; }`); + a.A(``); + + if (field.IsSegmented) + { + a.A(`let segmentList = ${JSON.stringify(field.SegmentList)};`); + a.A(`let bestNumber = 0;`); + a.A(`let bestValue = segmentList[0].low;`); + a.A(`let bestDiff = Math.abs(val - bestValue);`); + a.A(``); + a.A(`for (let i = 0; i < segmentList.length; ++i)`); + a.A(`{`); + a.IncrIndent(); + a.A(`let segment = segmentList[i];`); + a.A(`let rawIndex = (val - segment.low) / segment.step;`); + a.A(`let candidateIdxList = [Math.floor(rawIndex), Math.ceil(rawIndex)];`); + a.A(`for (let candidateIdx of candidateIdxList)`); + a.A(`{`); + a.IncrIndent(); + a.A(`if (candidateIdx < 0) { candidateIdx = 0; }`); + a.A(`else if (candidateIdx >= segment.count) { candidateIdx = segment.count - 1; }`); + a.A(`let candidateValue = segment.low + (candidateIdx * segment.step);`); + a.A(`let candidateNumber = segment.base + candidateIdx;`); + a.A(`let candidateDiff = Math.abs(val - candidateValue);`); + a.A(`if (candidateDiff < bestDiff || (Math.abs(candidateDiff - bestDiff) < 1e-9 && candidateValue > bestValue))`); + a.A(`{`); + a.IncrIndent(); + a.A(`bestDiff = candidateDiff;`); + a.A(`bestValue = candidateValue;`); + a.A(`bestNumber = candidateNumber;`); + a.DecrIndent(); + a.A(`}`); + a.DecrIndent(); + a.A(`}`); + a.DecrIndent(); + a.A(`}`); + a.A(``); + a.A(`return bestNumber;`); + } + else + { + a.A(`let retVal = ((val - ${field.lowValue}) / ${field.stepSize});`); + a.A(`retVal = Math.round(retVal);`); + a.A(`return retVal;`); + } + + a.DecrIndent(); + a.A(`}`); + a.DecrIndent(); + + a.A(` `); + + // Encoded Number Getter + a.IncrIndent(); + a.A(`Get${field.name}${field.unit}Number()`); + a.A(`{`); + a.IncrIndent(); + + a.A(`return this.Get${field.name}${field.unit}NumberFromValue(this.Get${field.name}${field.unit}());`); + + a.DecrIndent(); + a.A(`}`); + a.DecrIndent(); + + a.A(` `); + + a.IncrIndent(); + a.A(`Get${field.name}${field.unit}ValueFromNumber(inputVal)`); + a.A(`{`); + a.IncrIndent(); + + a.A(`let val = inputVal ?? 0;`); + a.A(`if (val < 0) { val = 0; }`); + a.A(`else if (val >= ${field.NumValues}) { val = ${field.NumValues - 1}; }`); + a.A(``); + + if (field.IsSegmented) + { + a.A(`let segmentList = ${JSON.stringify(field.SegmentList)};`); + a.A(`for (let i = 0; i < segmentList.length; ++i)`); + a.A(`{`); + a.IncrIndent(); + a.A(`let segment = segmentList[i];`); + a.A(`if (val >= segment.base && val < segment.base + segment.count)`); + a.A(`{`); + a.IncrIndent(); + a.A(`let localIndex = val - segment.base;`); + a.A(`return segment.low + (localIndex * segment.step);`); + a.DecrIndent(); + a.A(`}`); + a.DecrIndent(); + a.A(`}`); + a.A(`let segment = segmentList[segmentList.length - 1];`); + a.A(`return segment.low + ((segment.count - 1) * segment.step);`); + } + else + { + a.A(`return ${field.lowValue} + (val * ${field.stepSize});`); + } + + a.DecrIndent(); + a.A(`}`); + a.DecrIndent(); + } + + a.A(` `); + + // Encode + + // arrange application fields in reverse order + // but ensure the original order of header fields. + // this allows decode to pull out the "first" application field + // consistently, even if the fields after it + // change, are added, or revmoed. + // this isn't an expected feature, but a good feature as it protects + // legacy data in the event of future change as much as possible. + let fieldEncodeList = this.json.fieldList.slice(); + let fieldListApp = []; + let fieldListHdr = []; + for (const field of fieldEncodeList) + { + if (field.name.substr(0, 3) == "Hdr") + { + fieldListHdr.push(field); + } + else + { + fieldListApp.push(field); + } + } + + // reverse the application fields in-place + fieldListApp.reverse(); + + // re-make the field list + fieldEncodeList = []; + for (const field of fieldListApp) + { + fieldEncodeList.push(field); + } + for (const field of fieldListHdr) + { + fieldEncodeList.push(field); + } + + a.IncrIndent(); + a.A(`Encode()`); + a.A(`{`); + a.IncrIndent(); + + a.A(`let val = 0;`); + a.A(``); + + a.A(`// combine field values`); + for (let field of fieldEncodeList) + { + a.A(`val *= ${field.NumValues}; val += this.Get${field.name}${field.unit}Number();`); + } + + a.A(``); + + a.A(`// encode into power`); + a.A(`let powerVal = val % 19; val = Math.floor(val / 19);`); + a.A(`let powerDbm = this.wsprEncoded.EncodeNumToPower(powerVal);`); + a.A(``); + a.A(`// encode into grid`); + a.A(`let g4Val = val % 10; val = Math.floor(val / 10);`); + a.A(`let g3Val = val % 10; val = Math.floor(val / 10);`); + a.A(`let g2Val = val % 18; val = Math.floor(val / 18);`); + a.A(`let g1Val = val % 18; val = Math.floor(val / 18);`); + a.A(``); + a.A(`let g1 = String.fromCharCode("A".charCodeAt(0) + g1Val);`); + a.A(`let g2 = String.fromCharCode("A".charCodeAt(0) + g2Val);`); + a.A(`let g3 = String.fromCharCode("0".charCodeAt(0) + g3Val);`); + a.A(`let g4 = String.fromCharCode("0".charCodeAt(0) + g4Val);`); + a.A(`let grid = g1 + g2 + g3 + g4;`); + a.A(``); + a.A(`// encode into callsign`); + a.A(`let id6Val = val % 26; val = Math.floor(val / 26);`); + a.A(`let id5Val = val % 26; val = Math.floor(val / 26);`); + a.A(`let id4Val = val % 26; val = Math.floor(val / 26);`); + a.A(`let id2Val = val % 36; val = Math.floor(val / 36);`); + a.A(``); + a.A(`let id2 = this.wsprEncoded.EncodeBase36(id2Val);`); + a.A(`let id4 = String.fromCharCode("A".charCodeAt(0) + id4Val);`); + a.A(`let id5 = String.fromCharCode("A".charCodeAt(0) + id5Val);`); + a.A(`let id6 = String.fromCharCode("A".charCodeAt(0) + id6Val);`); + a.A(`let call = this.id13.at(0) + id2 + this.id13.at(1) + id4 + id5 + id6;`); + a.A(``); + a.A(`// capture results`); + a.A(`this.call = call;`); + a.A(`this.grid = grid;`); + a.A(`this.powerDbm = powerDbm;`); + + a.DecrIndent(); + a.A(`}`); + a.DecrIndent(); + + a.A(``); + + // Decode + + // get an entire-list reversed copy of the encoded field order + let fieldDecodeList = fieldEncodeList.slice().reverse(); + + a.IncrIndent(); + a.A(`Decode()`); + a.A(`{`); + a.IncrIndent(); + a.A(`// pull in inputs`); + a.A(`let call = this.GetCall();`); + a.A(`let grid = this.GetGrid();`); + a.A(`let powerDbm = this.GetPowerDbm();`); + a.A(``); + a.A(`// break call down`); + a.A(`let id2Val = this.wsprEncoded.DecodeBase36(call.charAt(1));`); + a.A(`let id4Val = call.charAt(3).charCodeAt(0) - "A".charCodeAt(0);`); + a.A(`let id5Val = call.charAt(4).charCodeAt(0) - "A".charCodeAt(0);`); + a.A(`let id6Val = call.charAt(5).charCodeAt(0) - "A".charCodeAt(0);`); + a.A(``); + a.A(`// break grid down`); + a.A(`let g1Val = grid.charAt(0).charCodeAt(0) - "A".charCodeAt(0);`); + a.A(`let g2Val = grid.charAt(1).charCodeAt(0) - "A".charCodeAt(0);`); + a.A(`let g3Val = grid.charAt(2).charCodeAt(0) - "0".charCodeAt(0);`); + a.A(`let g4Val = grid.charAt(3).charCodeAt(0) - "0".charCodeAt(0);`); + a.A(``); + a.A(`// break power down`); + a.A(`let powerVal = this.wsprEncoded.DecodePowerToNum(powerDbm);`); + a.A(``); + a.A(`// combine values into single integer`); + a.A(`let val = 0;`); + a.A(`val *= 36; val += id2Val;`); + a.A(`val *= 26; val += id4Val; // spaces aren't used, so 26 not 27`); + a.A(`val *= 26; val += id5Val; // spaces aren't used, so 26 not 27`); + a.A(`val *= 26; val += id6Val; // spaces aren't used, so 26 not 27`); + a.A(`val *= 18; val += g1Val;`); + a.A(`val *= 18; val += g2Val;`); + a.A(`val *= 10; val += g3Val;`); + a.A(`val *= 10; val += g4Val;`); + a.A(`val *= 19; val += powerVal;`); + a.A(``); + a.A(`// extract field values`); + + for (let field of fieldDecodeList) + { + a.A(`this.Set${field.name}${field.unit}(this.Get${field.name}${field.unit}ValueFromNumber(val % ${field.NumValues})); val = Math.floor(val / ${field.NumValues});`); + } + + a.DecrIndent(); + a.A(`}`); + a.DecrIndent(); + + a.A(``); + + // SetCall + a.IncrIndent(); + a.A(`SetCall(inputVal)`); + a.A(`{`); + a.IncrIndent(); + a.A(`this.call = inputVal;`) + a.DecrIndent(); + a.A(`}`); + a.DecrIndent(); + + a.A(``); + + // GetCall + a.IncrIndent(); + a.A(`GetCall()`); + a.A(`{`); + a.IncrIndent(); + a.A(`return this.call;`) + a.DecrIndent(); + a.A(`}`); + a.DecrIndent(); + + a.A(``); + + // SetGrid + a.IncrIndent(); + a.A(`SetGrid(inputVal)`); + a.A(`{`); + a.IncrIndent(); + a.A(`this.grid = inputVal;`) + a.DecrIndent(); + a.A(`}`); + a.DecrIndent(); + + a.A(``); + + // GetGrid + a.IncrIndent(); + a.A(`GetGrid()`); + a.A(`{`); + a.IncrIndent(); + a.A(`return this.grid;`) + a.DecrIndent(); + a.A(`}`); + a.DecrIndent(); + + a.A(``); + + // SetPowerDbm + a.IncrIndent(); + a.A(`SetPowerDbm(inputVal)`); + a.A(`{`); + a.IncrIndent(); + a.A(`this.powerDbm = inputVal;`) + a.DecrIndent(); + a.A(`}`); + a.DecrIndent(); + + a.A(``); + + // GetPowerDbm + a.IncrIndent(); + a.A(`GetPowerDbm()`); + a.A(`{`); + a.IncrIndent(); + a.A(`return parseInt(this.powerDbm);`) + a.DecrIndent(); + a.A(`}`); + a.DecrIndent(); + + a.A(`}`); + + let c = a.Get(); + + return c; + } +} + diff --git a/js/WsprMessageCandidate.js b/js/WsprMessageCandidate.js new file mode 100644 index 0000000..24f91cb --- /dev/null +++ b/js/WsprMessageCandidate.js @@ -0,0 +1,250 @@ +/* +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 { Base } from './Base.js'; + +// return the subset of msgs within a list that are not rejected +export function NonRejectedOnlyFilter(msgList) +{ + let msgListIsCandidate = []; + + for (let msg of msgList) + { + if (msg.IsNotRejected()) + { + msgListIsCandidate.push(msg); + } + } + + return msgListIsCandidate; +}; + +export class WsprMessageCandidate +{ + constructor() + { + // The type of message, regular or telemetry + // (the specific type of telemetry is not specified here) + this.type = "regular"; + + // The fields of the wspr message + this.fields = { + callsign: "", + grid4 : "", + powerDbm: "", + }; + + // All the rx reports with the same wspr fields, but different rx freq, rx call, etc + // + // Regular rxRecord: + // { + // "time" : "2024-10-22 15:04:00", + // "min" : 4, + // "callsign" : "KD2KDD", + // "grid4" : "FN20", + // "gridRaw" : "FN20", + // "powerDbm" : 13, + // "rxCallsign": "AC0G", + // "rxGrid" : "EM38ww", + // "frequency" : 14097036 + // } + // + // Telemetry rxRecord: + // { + // "time" : "2024-10-22 15:06:00", + // "id1" : "1", + // "id3" : "2", + // "min" : 6, + // "callsign" : "1Y2QQJ", + // "grid4" : "OC04", + // "powerDbm" : 37, + // "rxCallsign": "AB4EJ", + // "rxGrid" : "EM63fj", + // "frequency" : 14097036 + // } + // + this.rxRecordList = []; + + // Details about Decode attempt and results. + // Only has useful information when type = telemetry + this.decodeDetails = { + type: "basic", // basic or extended + + // actual decoded data, by type + basic: {}, // the fields of a decoded basic message + extended: { + // human-friendly name for the known extended telemetry type + prettyType: "", + + // the codec instance for the extended type. + // + // for any enumerated type identified, including user-defined, this will be + // a standalone instance of that codec, with the data already ingested and + // ready for reading. + // + // a user-defined message may or may not be configured with a field def. + // the only guarantee is that the codec can read the headers and generally + // operate itself (ie it may not have application fields). + // + // all codec instances should be considered read-only. + codec: null, + }, + }; + + // States: + // - candidate - possibly your message + // - confirmed - believed to definitely be your message + // - rejected - no longer considered possible to be your message, or + // so ambiguous as to need to be rejected as a possible + // certainty that it is yours + this.candidateState = "candidate"; + + // Details of the filters applied that ultimately looked at, + // and perhaps changed, the status of candidateState. + // Structure defined in the CandidateFilterBase implementation. + // + // Meant to be an audit. + this.candidateFilterAuditList = [ + ]; + + // linkage back to storage location (debug) + this.windowShortcut = null; + this.windowSlotName = ``; + this.windowSlotShortcut = null; + this.windowSlotShortcutIdx = 0; + } + + IsCandidate() + { + return this.candidateState == "candidate"; + } + + IsConfirmed() + { + return this.candidateState == "confirmed"; + } + + IsNotRejected() + { + return this.candidateState != "rejected"; + } + + IsType(type) + { + return this.type == type; + } + + IsRegular() + { + return this.IsType("regular"); + } + + IsTelemetry() + { + return this.IsType("telemetry"); + } + + IsTelemetryType(type) + { + return this.IsTelemetry() && this.decodeDetails.type == type; + } + + IsTelemetryBasic() + { + return this.IsTelemetryType("basic"); + } + + IsTelemetryExtended() + { + return this.IsTelemetryType("extended"); + } + + IsExtendedTelemetryUserDefined() + { + let retVal = false; + + if (this.IsTelemetryExtended()) + { + if (this.decodeDetails.extended.codec.GetHdrTypeEnum() == 0) + { + retVal = true; + } + } + + return retVal; + } + + IsExtendedTelemetryVendorDefined() + { + let retVal = false; + + if (this.IsTelemetryExtended()) + { + if (this.decodeDetails.extended.codec.GetHdrTypeEnum() == 15) + { + retVal = true; + } + } + + return retVal; + } + + GetCodec() + { + return this.decodeDetails.extended.codec; + } + + CreateAuditRecord(type, note) + { + return { + // Enumerated type of the audit. + // Tells you how to interpret the object. + type: type, + + // Note, in human terms, of anything the filter wanted to note about + // this message in the course of its processing. + note: note, + + // Any other structure is type-dependent. + // ... + }; + } + + AddAuditRecord(type, note) + { + let audit = this.CreateAuditRecord(type, note); + + // add audit record + this.candidateFilterAuditList.push(audit); + + return audit; + } + + Confirm(type, note) + { + this.candidateState = "confirmed"; + + let audit = this.AddAuditRecord(type, note); + + return audit; + } + + Reject(type, note) + { + // change the message state + this.candidateState = "rejected"; + + let audit = this.AddAuditRecord(type, note); + + console.log(`msg.Reject("${type}", "${note}")`); + console.log(this); + + // return audit record for any additional details to be added + return audit; + } +} diff --git a/js/WsprSearchResultDataTableBuilder.js b/js/WsprSearchResultDataTableBuilder.js new file mode 100644 index 0000000..98d4876 --- /dev/null +++ b/js/WsprSearchResultDataTableBuilder.js @@ -0,0 +1,1170 @@ +/* +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 { TabularData } from '../../../../js/TabularData.js'; +import { ColumnBuilderRegularType1 } from './WsprSearchResultDataTableColumnBuilderRegularType1.js'; +import { ColumnBuilderHeartbeat } from './WsprSearchResultDataTableColumnBuilderHeartbeat.js'; +import { ColumnBuilderTelemetryBasic } from './WsprSearchResultDataTableColumnBuilderTelemetryBasic.js'; +import { ColumnBuilderExpandedBasicTelemetry } from './WsprSearchResultDataTableColumnBuilderExpandedBasicTelemetry.js'; +import { ColumnBuilderTelemetryExtendedUserOrVendorDefined } from './WsprSearchResultDataTableColumnBuilderTelemetryExtendedUserOrVendorDefined.js'; +import { ColumnBuilderHighResLocation } from './WsprSearchResultDataTableColumnBuilderHighResLocation.js'; +import { CodecHeartbeat } from './CodecHeartbeat.js'; +import { CodecExpandedBasicTelemetry } from './CodecExpandedBasicTelemetry.js'; +import { CodecHighResLocation } from './CodecHighResLocation.js'; + +import { WSPREncoded } from '/js/WSPREncoded.js'; +import { GreatCircle } from '/js/GreatCircle.js'; + +import './suncalc.js'; + + +// Adapter to the WsprSearch results. +// Extracts data from the results where unambiguous. +// Enriches with maximum value add +// Including decoding, unit converting, etc +// +export class WsprSearchResultDataTableBuilder +extends Base +{ + constructor() + { + super(); + + this.codecHeartbeat = new CodecHeartbeat(); + this.codecExpandedBasicTelemetry = new CodecExpandedBasicTelemetry(); + this.codecHighResLocation = new CodecHighResLocation(); + } + + SetDebug(tf) + { + super.SetDebug(tf); + + this.t.SetCcGlobal(tf); + } + + BuildDataTable(wsprSearch) + { + this.t.Reset(); + this.t.Event(`WsprSearchResultDataTableBuilder::BuildTable Start`); + + let codecMakerUserDefinedList = wsprSearch.GetCodecMakerUserDefinedList(); + let codecMakerVendorDefinedList = wsprSearch.GetCodecMakerVendorDefinedList(); + + // find the set of column builders that apply to this dataset + let cbSetOrdered = new Set([ + new ColumnBuilderRegularType1(), + new ColumnBuilderTelemetryBasic(), + new ColumnBuilderHeartbeat(), + new ColumnBuilderExpandedBasicTelemetry(), + new ColumnBuilderHighResLocation(), + ]); + + for (let slot = 0; slot < 5; ++slot) + { + cbSetOrdered.add(new ColumnBuilderTelemetryExtendedUserOrVendorDefined(slot, codecMakerUserDefinedList[slot], "ud")); + } + + for (let slot = 0; slot < 5; ++slot) + { + cbSetOrdered.add(new ColumnBuilderTelemetryExtendedUserOrVendorDefined(slot, codecMakerVendorDefinedList[slot], "vd")); + } + + // take note of which column builders have matched the data + let cbSetNotSeen = new Set(cbSetOrdered); + let cbSetSeen = new Set() + wsprSearch.ForEachWindow((time, slotMsgList) => { + let retVal = true; + + for (const cb of cbSetNotSeen) + { + if (cb.MatchWindow?.(slotMsgList)) + { + cbSetSeen.add(cb); + cbSetNotSeen.delete(cb); + } + } + + // search across every slot + for (const msg of slotMsgList) + { + if (msg) + { + for (const cb of cbSetNotSeen) + { + if (cb.Match?.(msg)) + { + cbSetSeen.add(cb); + cbSetNotSeen.delete(cb); + } + } + } + + // no need to keep looking if every supported builder is known already + if (cbSetNotSeen.size == 0) + { + retVal = false; + + break; + } + } + + return retVal; + }); + + // columns may wind up ordered oddly, since (say) in the first row, slot 4 might be seen, + // but then in the second row, slot 3 then slot 4. This would leave an ordering of + // slot 4, slot 3. + // we sort by the original order here to restore. + let cbSetSeenNew = new Set(); + + for (const cb of cbSetOrdered) + { + if (cbSetSeen.has(cb)) + { + cbSetSeenNew.add(cb); + } + } + + cbSetSeen = cbSetSeenNew; + + // build data table + let colNameList = []; + colNameList.push(... [ + "DateTimeUtc", + "DateTimeLocal", + ]); + + for (const cb of cbSetSeen) + { + colNameList.push(... cb.GetColNameList()); + } + + let td = new TabularData([colNameList]); + + // populate data table + wsprSearch.ForEachWindow((time, slotMsgList) => { + let row = td.AddRow(); + + // fill out time columns + td.Set(row, "DateTimeUtc", time); + td.Set(row, "DateTimeLocal", utl.ConvertUtcToLocal(time)); + + // only let a column builder run once per window + let cbSetUse = new Set(cbSetSeen); + + for (const cb of cbSetUse) + { + if (cb.MatchWindow?.(slotMsgList)) + { + let colNameList = cb.GetColNameList(); + let valList = cb.GetValListForWindow(slotMsgList); + + for (let i = 0; i < colNameList.length; ++i) + { + td.Set(row, colNameList[i], valList[i]); + } + + cbSetUse.delete(cb); + } + } + + for (const msg of slotMsgList) + { + if (msg) + { + for (const cb of cbSetUse) + { + if (cb.Match?.(msg)) + { + let colNameList = cb.GetColNameList(); + let valList = cb.GetValList(msg) + + for (let i = 0; i < colNameList.length; ++i) + { + td.Set(row, colNameList[i], valList[i]); + } + + // only let a column builder run once per window + cbSetUse.delete(cb); + + break; + } + } + + // if all column builders have run, nothing left to do for this window + if (cbSetUse.size == 0) + { + break; + } + } + } + }); + + // add column metadata + for (const cb of cbSetSeen) + { + // these must be the same length + let colNameList = cb.GetColNameList(); + let metaDataList = cb.GetColMetaDataList(); + + for (let i = 0; i < colNameList.length; ++i) + { + let colName = colNameList[i]; + let colMetaData = metaDataList[i]; + + td.SetColMetaData(colName, colMetaData); + } + } + + // add row metadata + let idx = 0; + wsprSearch.ForEachWindow((time, slotMsgList) => { + td.SetRowMetaData(idx, { + time, + slotMsgList, + }); + ++idx; + }); + + this.SynthesizeData(td, wsprSearch); + + this.t.Event(`WsprSearchResultDataTableBuilder::BuildTable End`); + + return td; + } + + GetRxStationCount(slotMsgList) + { + let rxStationSet = new Set(); + + for (const msg of slotMsgList) + { + if (!msg || !Array.isArray(msg.rxRecordList)) + { + continue; + } + + for (const rxRecord of msg.rxRecordList) + { + // rxCallsign identifies receiving station in the raw query row. + let rxStation = rxRecord?.rxCallsign; + if (rxStation == undefined || rxStation == null || rxStation === "") + { + continue; + } + + rxStationSet.add(rxStation); + } + } + + return rxStationSet.size; + } + + GetRowTxLatLng(td, row) + { + let lat = td.Idx("Lat") != undefined ? td.Get(row, "Lat") : null; + let lng = td.Idx("Lng") != undefined ? td.Get(row, "Lng") : null; + + if (lat != null && lng != null) + { + return [lat, lng]; + } + + let overlap = td.GetRowMetaData(row)?.overlap; + let hasOtherLocationBearing = !!(overlap?.selected?.bt || overlap?.selected?.ebt || overlap?.selected?.hrl); + + if (!hasOtherLocationBearing) + { + let regLat = td.Idx("RegLat") != undefined ? td.Get(row, "RegLat") : null; + let regLng = td.Idx("RegLng") != undefined ? td.Get(row, "RegLng") : null; + + if (regLat != null && regLng != null) + { + return [regLat, regLng]; + } + } + + return [null, null]; + } + + GetRowTxLatLngForSolarAngle(td, row) + { + let [lat, lng] = this.GetRowTxLatLng(td, row); + + if (lat != null && lng != null) + { + return [lat, lng]; + } + + let overlap = td.GetRowMetaData(row)?.overlap; + let hasOtherLocationBearing = !!(overlap?.selected?.bt || overlap?.selected?.ebt || overlap?.selected?.hrl); + let regGrid = td.Idx("RegGrid") != undefined ? td.Get(row, "RegGrid") : null; + + if (regGrid != null && !hasOtherLocationBearing) + { + return WSPREncoded.DecodeMaidenheadToDeg(regGrid); + } + + return [null, null]; + } + + SynthesizeData(td, wsprSearch) + { + // Build the cross-message overlap view first so later synthesized columns + // can consume the resolved/raw-derived families as ordinary table data. + this.SynthesizeOverlapFamilies(td); + this.ShortenTime(td); + this.SynthesizeTxFreqMhz(td, wsprSearch); + this.SynthesizeSolarAngle(td); + this.SynthesizeRxStationCount(td); + this.SynthesizeWinFreqDrift(td, wsprSearch); + this.SynthesizeAltChg(td); + this.SynthesizeDistance(td); + this.SynthesizeSpeedGPS(td); + } + + SynthesizeTxFreqMhz(td, wsprSearch) + { + if (td.Idx("TxFreqHzIdx") == undefined) + { + return; + } + + let band = wsprSearch?.band ?? ""; + td.AppendGeneratedColumns([ + "TxFreqMhz" + ], row => { + let txFreqHzIdx = td.Get(row, "TxFreqHzIdx"); + let txFreqHz = this.codecHeartbeat.DecodeTxFreqHzFromBand(band, txFreqHzIdx); + let txFreqMhz = txFreqHz != null ? txFreqHz / 1000000 : null; + + return [txFreqMhz]; + }, true); + + td.SetColMetaData("TxFreqMhz", {}); + } + + ShortenTime(td) + { + td.GenerateModifiedColumn([ + "DateTimeUtc" + ], row => { + let retVal = [ + td.Get(row, "DateTimeUtc").substr(0, 16), + ]; + + return retVal; + }); + + td.GenerateModifiedColumn([ + "DateTimeLocal" + ], row => { + let retVal = [ + td.Get(row, "DateTimeLocal").substr(0, 16), + ]; + + return retVal; + }); + } + + GetSelectedOverlapMessages(slotMsgList) + { + let selected = { + rt1: null, + bt: null, + ebt: null, + hrl: null, + }; + + if (!Array.isArray(slotMsgList)) + { + return selected; + } + + let msg0 = slotMsgList[0]; + if (msg0?.IsRegular?.()) + { + selected.rt1 = { slot: 0, msg: msg0 }; + } + + let msg1 = slotMsgList[1]; + if (msg1?.IsTelemetryBasic?.()) + { + selected.bt = { slot: 1, msg: msg1 }; + } + + for (let slot = Math.min(4, slotMsgList.length - 1); slot >= 1; --slot) + { + let msg = slotMsgList[slot]; + if (!msg?.IsTelemetryExtended?.()) + { + continue; + } + + let codec = msg.GetCodec?.(); + if (selected.ebt == null && this.codecExpandedBasicTelemetry.IsCodecExpandedBasicTelemetry(codec)) + { + selected.ebt = { slot, msg }; + } + if (selected.hrl == null && this.codecHighResLocation.IsCodecHighResLocation(codec)) + { + selected.hrl = { slot, msg }; + } + + if (selected.ebt && selected.hrl) + { + break; + } + } + + return selected; + } + + SynthesizeOverlapFamilies(td) + { + // Decide which overlap families exist at all from raw builder output. + // This keeps raw extraction independent while letting synthesis add only + // the resolved/derived columns that make sense for this dataset. + let hasRegLocation = td.Idx("RegGrid") != undefined || td.Idx("RegLat") != undefined || td.Idx("RegLng") != undefined; + let hasBtLocation = td.Idx("BtGrid56") != undefined; + let hasEbtLocation = td.Idx("EbtLatitudeIdx") != undefined; + let hasHiResLocation = td.Idx("HiResLatitudeIdx") != undefined; + + let needsLocationFamily = hasRegLocation || hasBtLocation || hasEbtLocation || hasHiResLocation; + let needsTemperatureFamily = td.Idx("BtTempC") != undefined || td.Idx("EbtTempF") != undefined; + let needsVoltageFamily = td.Idx("BtVoltage") != undefined || td.Idx("EbtVoltage") != undefined; + let needsAltitudeFamily = td.Idx("BtAltM") != undefined || td.Idx("EbtAltFt") != undefined; + let needsSpeedFamily = td.Idx("BtKPH") != undefined || td.Idx("BtMPH") != undefined; + + // Selection happens before synthesis so later family logic can stay + // readable: each row already knows which message instance is "the" + // RT1/BT/EBT/HRL selected for that window. + for (let row = 0; row < td.Length(); ++row) + { + let metaData = td.GetRowMetaData(row) ?? {}; + metaData.overlapSelected = this.GetSelectedOverlapMessages(metaData.slotMsgList ?? []); + td.SetRowMetaData(row, metaData); + } + + let colList = []; + if (hasBtLocation) { colList.push("BtGrid6", "BtLat", "BtLng"); } + if (hasEbtLocation) { colList.push("EbtLat", "EbtLng"); } + if (hasHiResLocation) { colList.push("HiResLat", "HiResLng"); } + if (needsLocationFamily) { colList.push("Lat", "Lng"); } + if (needsTemperatureFamily) { colList.push("TempF", "TempC"); } + if (needsVoltageFamily) { colList.push("Voltage"); } + if (needsAltitudeFamily) { colList.push("AltFt", "AltM"); } + if (needsSpeedFamily) { colList.push("KPH", "MPH"); } + + let SetOverlapMetaData = (row, selected, sourceByFamily = {}, style = null) => { + let metaData = td.GetRowMetaData(row) ?? {}; + delete metaData.overlapSelected; + metaData.overlap = { + selected, + resolved: { sourceByFamily }, + style: style ?? { + dimmedCols: new Set(), + italicCols: new Set(), + precisionByCol: {}, + }, + }; + td.SetRowMetaData(row, metaData); + }; + + if (colList.length == 0) + { + for (let row = 0; row < td.Length(); ++row) + { + let selected = td.GetRowMetaData(row)?.overlapSelected ?? this.GetSelectedOverlapMessages([]); + SetOverlapMetaData(row, selected); + } + + return; + } + + td.AppendGeneratedColumns(colList, row => { + let metaData = td.GetRowMetaData(row) ?? {}; + let selected = metaData.overlapSelected ?? this.GetSelectedOverlapMessages([]); + let sourceByFamily = {}; + let style = { + dimmedCols: new Set(), + italicCols: new Set(), + precisionByCol: {}, + }; + let out = new Map(); + + let regGrid = td.Idx("RegGrid") != undefined ? td.Get(row, "RegGrid") : null; + let regLat = td.Idx("RegLat") != undefined ? td.Get(row, "RegLat") : null; + let regLng = td.Idx("RegLng") != undefined ? td.Get(row, "RegLng") : null; + let hasSelectedOtherLocationBearing = !!(selected.bt || selected.ebt || selected.hrl); + + if (regLat != null && regLng != null) + { + style.precisionByCol.RegLat = 2; + style.precisionByCol.RegLng = 2; + } + + // Location family: + // 1. derive raw per-message location columns that require cross-message + // context (RT1 reference grid) + // 2. choose the winning location source by latest usable selected message + // 3. populate resolved location plus styling for dimmed/superseded raw data + let btGpsValid = td.Idx("BtGpsValid") != undefined ? !!td.Get(row, "BtGpsValid") : false; + let btGrid56 = td.Idx("BtGrid56") != undefined ? td.Get(row, "BtGrid56") : null; + let btGrid6 = regGrid != null && btGrid56 != null ? `${regGrid}${btGrid56}` : null; + let btLat = null; + let btLng = null; + if (btGpsValid && btGrid6 != null) + { + [btLat, btLng] = WSPREncoded.DecodeMaidenheadToDeg(btGrid6); + style.precisionByCol.BtLat = 4; + style.precisionByCol.BtLng = 4; + } + if (hasBtLocation) + { + out.set("BtGrid6", btGrid6); + out.set("BtLat", btLat); + out.set("BtLng", btLng); + if (!btGpsValid) + { + ["BtGrid56", "BtGrid6", "BtLat", "BtLng"].forEach(col => style.dimmedCols.add(col)); + } + } + + let ebtGpsValid = td.Idx("EbtGpsValid") != undefined ? !!td.Get(row, "EbtGpsValid") : false; + let ebtLat = null; + let ebtLng = null; + if (hasEbtLocation && regGrid != null && ebtGpsValid) + { + let location = this.codecExpandedBasicTelemetry.DecodeFieldValuesToLocation( + regGrid, + td.Get(row, "EbtLatitudeIdx"), + td.Get(row, "EbtLongitudeIdx"), + ); + + ebtLat = location?.lat ?? null; + ebtLng = location?.lng ?? null; + style.precisionByCol.EbtLat = 6; + style.precisionByCol.EbtLng = 6; + } + if (hasEbtLocation) + { + out.set("EbtLat", ebtLat); + out.set("EbtLng", ebtLng); + if (!ebtGpsValid) + { + ["EbtLatitudeIdx", "EbtLongitudeIdx", "EbtLat", "EbtLng"].forEach(col => style.dimmedCols.add(col)); + } + } + + let hiResReference = td.Idx("HiResReference") != undefined ? td.Get(row, "HiResReference") : null; + let hiResValid = Number(hiResReference) == 1; + let hiResLat = null; + let hiResLng = null; + if (hasHiResLocation && regGrid != null && hiResValid) + { + let location = this.codecHighResLocation.DecodeFieldValuesToLocation( + regGrid, + hiResReference, + td.Get(row, "HiResLatitudeIdx"), + td.Get(row, "HiResLongitudeIdx"), + ); + + hiResLat = location?.lat ?? null; + hiResLng = location?.lng ?? null; + style.precisionByCol.HiResLat = 6; + style.precisionByCol.HiResLng = 6; + } + if (hasHiResLocation) + { + out.set("HiResLat", hiResLat); + out.set("HiResLng", hiResLng); + if (!hiResValid) + { + ["HiResReference", "HiResLatitudeIdx", "HiResLongitudeIdx", "HiResLat", "HiResLng"].forEach(col => style.dimmedCols.add(col)); + } + } + + let locationCandidateList = []; + if (selected.bt && btGpsValid && btLat != null && btLng != null) + { + locationCandidateList.push({ + source: "BT", + slot: selected.bt.slot, + lat: btLat, + lng: btLng, + italicCols: ["BtGrid56", "BtGrid6", "BtLat", "BtLng"], + precision: 4, + }); + } + if (selected.ebt && ebtGpsValid && ebtLat != null && ebtLng != null) + { + locationCandidateList.push({ + source: "EBT", + slot: selected.ebt.slot, + lat: ebtLat, + lng: ebtLng, + grid: null, + italicCols: ["EbtLatitudeIdx", "EbtLongitudeIdx", "EbtLat", "EbtLng"], + precision: 6, + }); + } + if (selected.hrl && hiResValid && hiResLat != null && hiResLng != null) + { + locationCandidateList.push({ + source: "HRL", + slot: selected.hrl.slot, + lat: hiResLat, + lng: hiResLng, + grid: null, + italicCols: ["HiResReference", "HiResLatitudeIdx", "HiResLongitudeIdx", "HiResLat", "HiResLng"], + precision: 6, + }); + } + if (selected.rt1 && !hasSelectedOtherLocationBearing && regLat != null && regLng != null) + { + locationCandidateList.push({ + source: "RT1", + slot: selected.rt1.slot, + lat: regLat, + lng: regLng, + italicCols: ["RegGrid", "RegLat", "RegLng"], + precision: 2, + }); + } + + locationCandidateList.sort((a, b) => b.slot - a.slot); + let selectedLocation = locationCandidateList[0] ?? null; + sourceByFamily.location = selectedLocation?.source ?? null; + + if (needsLocationFamily) + { + out.set("Lat", selectedLocation?.lat ?? null); + out.set("Lng", selectedLocation?.lng ?? null); + if (selectedLocation) + { + style.precisionByCol.Lat = selectedLocation.precision; + style.precisionByCol.Lng = selectedLocation.precision; + } + } + + if (selectedLocation?.source != "RT1" && regGrid != null) + { + ["RegGrid", "RegLat", "RegLng"].forEach(col => style.italicCols.add(col)); + } + if (selectedLocation?.source != "BT" && selected.bt && btGpsValid && btLat != null && btLng != null) + { + ["BtGrid56", "BtGrid6", "BtLat", "BtLng"].forEach(col => style.italicCols.add(col)); + } + if (selectedLocation?.source != "EBT" && selected.ebt && ebtGpsValid && ebtLat != null && ebtLng != null) + { + ["EbtLatitudeIdx", "EbtLongitudeIdx", "EbtLat", "EbtLng"].forEach(col => style.italicCols.add(col)); + } + if (selectedLocation?.source != "HRL" && selected.hrl && hiResValid && hiResLat != null && hiResLng != null) + { + ["HiResReference", "HiResLatitudeIdx", "HiResLongitudeIdx", "HiResLat", "HiResLng"].forEach(col => style.italicCols.add(col)); + } + + // Temperature family: + // choose the latest selected source carrying temperature, independent + // of GPS validity since temperature is not GPS-derived. + let temperatureCandidateList = []; + if (selected.bt && td.Idx("BtTempF") != undefined) + { + temperatureCandidateList.push({ + source: "BT", + slot: selected.bt.slot, + tempF: td.Get(row, "BtTempF"), + tempC: td.Get(row, "BtTempC"), + italicCols: ["BtTempC", "BtTempF"], + }); + } + if (selected.ebt && td.Idx("EbtTempF") != undefined) + { + temperatureCandidateList.push({ + source: "EBT", + slot: selected.ebt.slot, + tempF: td.Get(row, "EbtTempF"), + tempC: td.Get(row, "EbtTempC"), + italicCols: ["EbtTempC", "EbtTempF"], + }); + } + + temperatureCandidateList.sort((a, b) => b.slot - a.slot); + let selectedTemperature = temperatureCandidateList[0] ?? null; + if (needsTemperatureFamily) + { + out.set("TempF", selectedTemperature?.tempF ?? null); + out.set("TempC", selectedTemperature?.tempC ?? null); + } + sourceByFamily.temperature = selectedTemperature?.source ?? null; + for (let candidate of temperatureCandidateList) + { + if (candidate.source != selectedTemperature?.source) + { + candidate.italicCols.forEach(col => style.italicCols.add(col)); + } + } + + // Voltage family: + // same selection model as temperature: latest selected source wins. + let voltageCandidateList = []; + if (selected.bt && td.Idx("BtVoltage") != undefined) + { + voltageCandidateList.push({ + source: "BT", + slot: selected.bt.slot, + voltage: td.Get(row, "BtVoltage"), + italicCols: ["BtVoltage"], + }); + } + if (selected.ebt && td.Idx("EbtVoltage") != undefined) + { + voltageCandidateList.push({ + source: "EBT", + slot: selected.ebt.slot, + voltage: td.Get(row, "EbtVoltage"), + italicCols: ["EbtVoltage"], + }); + } + + voltageCandidateList.sort((a, b) => b.slot - a.slot); + let selectedVoltage = voltageCandidateList[0] ?? null; + if (needsVoltageFamily) + { + out.set("Voltage", selectedVoltage?.voltage ?? null); + } + sourceByFamily.voltage = selectedVoltage?.source ?? null; + for (let candidate of voltageCandidateList) + { + if (candidate.source != selectedVoltage?.source) + { + candidate.italicCols.forEach(col => style.italicCols.add(col)); + } + } + + // Altitude family: + // altitude is GPS-derived, so unusable sources are dimmed and excluded + // from selection before latest-slot precedence is applied. + if (td.Idx("BtAltM") != undefined && !btGpsValid) + { + ["BtAltM", "BtAltFt"].forEach(col => style.dimmedCols.add(col)); + } + if (td.Idx("EbtAltFt") != undefined && !ebtGpsValid) + { + ["EbtAltFt", "EbtAltM"].forEach(col => style.dimmedCols.add(col)); + } + + let altitudeCandidateList = []; + if (selected.bt && btGpsValid && td.Idx("BtAltM") != undefined) + { + altitudeCandidateList.push({ + source: "BT", + slot: selected.bt.slot, + altFt: td.Get(row, "BtAltFt"), + altM: td.Get(row, "BtAltM"), + italicCols: ["BtAltM", "BtAltFt"], + }); + } + if (selected.ebt && ebtGpsValid && td.Idx("EbtAltFt") != undefined) + { + altitudeCandidateList.push({ + source: "EBT", + slot: selected.ebt.slot, + altFt: td.Get(row, "EbtAltFt"), + altM: td.Get(row, "EbtAltM"), + italicCols: ["EbtAltFt", "EbtAltM"], + }); + } + + altitudeCandidateList.sort((a, b) => b.slot - a.slot); + let selectedAltitude = altitudeCandidateList[0] ?? null; + if (needsAltitudeFamily) + { + out.set("AltFt", selectedAltitude?.altFt ?? null); + out.set("AltM", selectedAltitude?.altM ?? null); + } + sourceByFamily.altitude = selectedAltitude?.source ?? null; + for (let candidate of altitudeCandidateList) + { + if (candidate.source != selectedAltitude?.source) + { + candidate.italicCols.forEach(col => style.italicCols.add(col)); + } + } + + // Speed family: + // BT is the only carrier, so synthesis just exposes the resolved form. + if (needsSpeedFamily) + { + let btMph = td.Idx("BtMPH") != undefined ? td.Get(row, "BtMPH") : null; + + out.set("KPH", td.Idx("BtKPH") != undefined ? td.Get(row, "BtKPH") : null); + out.set("MPH", btMph); + } + sourceByFamily.speed = selected.bt && (td.Idx("BtKPH") != undefined || td.Idx("BtMPH") != undefined) ? "BT" : null; + + // Persist the selection/result/style decisions so UI formatting and + // downstream synthesized columns can use the overlap model directly. + SetOverlapMetaData(row, selected, sourceByFamily, style); + + return colList.map(col => out.has(col) ? out.get(col) : null); + }); + + // Treat synthesized overlap columns like first-class table columns so + // downstream formatting, filtering, and chart ranges can reason about + // them the same way as raw builder output. + let tempCMinResolved = -50; + let tempCMaxResolved = 39; + if (td.Idx("TempC") != undefined) + { + td.ForEach(row => { + let rawVal = td.Get(row, "TempC"); + if (rawVal == null || rawVal === "") + { + return; + } + + let val = Number(rawVal); + if (Number.isFinite(val)) + { + if (val < -50) + { + tempCMinResolved = -51; + } + if (val > 39) + { + tempCMaxResolved = 39; + } + } + }); + } + + let voltageMinResolved = 3; + let voltageMaxResolved = 4.95; + if (td.Idx("Voltage") != undefined) + { + td.ForEach(row => { + let rawVal = td.Get(row, "Voltage"); + if (rawVal == null || rawVal === "") + { + return; + } + + let val = Number(rawVal); + if (Number.isFinite(val)) + { + if (val < 3 || val > 4.95) + { + voltageMinResolved = 1.8; + voltageMaxResolved = 7.0; + } + } + }); + } + + let colMetaDataList = new Map([ + ["BtGrid6", {}], + ["BtLat", { rangeMin: -90, rangeMax: 90 }], + ["BtLng", { rangeMin: -180, rangeMax: 180 }], + ["EbtLat", { rangeMin: -90, rangeMax: 90 }], + ["EbtLng", { rangeMin: -180, rangeMax: 180 }], + ["HiResLat", { rangeMin: -90, rangeMax: 90 }], + ["HiResLng", { rangeMin: -180, rangeMax: 180 }], + ["Lat", { rangeMin: -90, rangeMax: 90 }], + ["Lng", { rangeMin: -180, rangeMax: 180 }], + ["TempF", { rangeMin: utl.CtoF_Round(tempCMinResolved), rangeMax: utl.CtoF_Round(tempCMaxResolved) }], + ["TempC", { rangeMin: tempCMinResolved, rangeMax: tempCMaxResolved }], + ["Voltage", { rangeMin: voltageMinResolved, rangeMax: voltageMaxResolved }], + ["AltFt", { rangeMin: 0, rangeMax: 120000 }], + ["AltM", { rangeMin: 0, rangeMax: 36576 }], + ["KPH", { rangeMin: 0, rangeMax: utl.KnotsToKph_Round(82) }], + ["MPH", { rangeMin: 0, rangeMax: utl.KnotsToMph_Round(82) }], + ]); + + for (let col of colList) + { + td.SetColMetaData(col, colMetaDataList.get(col) ?? {}); + } + } + + SynthesizeSolarAngle(td) + { + if (td.Idx("Lat") == undefined && td.Idx("RegLat") == undefined) { return; } + + // synthesize solar angle + td.AppendGeneratedColumns([ + "SolAngle" + ], row => { + let retVal = [null]; + + let [lat, lng] = this.GetRowTxLatLngForSolarAngle(td, row); + + if (lat != null && lng != null) + { + let msSinceEpoch = utl.ParseTimeToMs(td.Get(row, "DateTimeLocal")); + + let sunPos = SunCalc.getPosition(msSinceEpoch, lat, lng); + + let elevation = sunPos.altitude * 180 / Math.PI; + + retVal = [Math.round(elevation)]; + } + + return retVal; + }, true); + } + + SynthesizeRxStationCount(td) + { + td.AppendGeneratedColumns([ + "RxStationCount" + ], row => { + let slotMsgList = td.GetRowMetaData(row)?.slotMsgList; + let retVal = [this.GetRxStationCount(slotMsgList || [])]; + + return retVal; + }, true); + } + + SynthesizeWinFreqDrift(td, wsprSearch) + { + td.AppendGeneratedColumns([ + "WinFreqDrift" + ], row => { + let time = td.GetRowMetaData(row)?.time; + let windowData = wsprSearch?.time__windowData?.get?.(time); + let retVal = [windowData?.fingerprintingData?.winFreqDrift ?? null]; + + return retVal; + }, true); + + td.SetColMetaData("WinFreqDrift", {}); + } + + SynthesizeAltChg(td) + { + if (td.Idx("AltM") == undefined) { return; } + + let altMlast = null; + let altMTimeLast = null; + + // synthesize altitude change + td.AppendGeneratedColumns([ + "AltChgMpm", "AltChgFpm" + ], row => { + let retVal = [null, null]; + + let altM = td.Get(row, "AltM"); + let altMTime = td.Get(row, "DateTimeLocal"); + + if (altM != null && altMTime != null && altMlast != null && altMTimeLast != null) + { + let altMTimeMs = utl.ParseTimeToMs(altMTime); + let altMTimeLastMs = utl.ParseTimeToMs(altMTimeLast); + + let altMTimeDiffMs = altMTimeMs - altMTimeLastMs; + let altMTimeDiffMin = altMTimeDiffMs / (60 * 1000); + + let altMDiff = altM - altMlast; + + let altMpm = Math.round(altMDiff / altMTimeDiffMin); + let altFpm = Math.round(utl.MtoFt(altMpm)); + + retVal = [altMpm, altFpm]; + } + + altMlast = altM; + altMTimeLast = altMTime; + + return retVal; + }, true); + + let rangeMinM = -150; + let rangeMaxM = 150; + + let rangeMinFt = utl.MtoFt_Round(rangeMinM); + let rangeMaxFt = utl.MtoFt_Round(rangeMaxM); + + td.SetColMetaData("AltChgMpm", { + rangeMin: rangeMinM, + rangeMax: rangeMaxM, + }); + td.SetColMetaData("AltChgFpm", { + rangeMin: rangeMinFt, + rangeMax: rangeMaxFt, + }); + } + + SynthesizeDistance(td) + { + if (td.Idx("Lat") == undefined && td.Idx("RegLat") == undefined) { return; } + + // synthesize distance traveled + let lastLat = null; + let lastLng = null; + td.AppendGeneratedColumns([ + "DistKm", "DistMi" + ], row => { + let retVal = [null, null]; + + let [lat, lng] = this.GetRowTxLatLng(td, row); + + if (lat != null && lng != null) + { + if (lastLat != null && lastLng != null) + { + let km = GreatCircle.distance(lastLat, lastLng, lat, lng, "KM"); + let mi = GreatCircle.distance(lastLat, lastLng, lat, lng, "MI"); + + retVal = [Math.round(km), Math.round(mi)]; + } + + lastLat = lat; + lastLng = lng; + } + + return retVal; + }, true); + } + + SynthesizeSpeedGPS(td) + { + if (td.Idx("Lat") == undefined || td.Idx("Lng") == undefined) { return; } + + let kphRawList = new Array(td.Length()).fill(null); + + // First build a raw segment-speed estimate that discounts movement inside + // the combined uncertainty envelope of the two location sources. + let rowLast = null; + td.ForEach((row, idx) => { + let [lat, lng] = this.GetRowTxLatLng(td, row); + if (lat == null || lng == null) + { + return; + } + + if (rowLast != null) + { + let [latLast, lngLast] = this.GetRowTxLatLng(td, rowLast); + if (latLast != null && lngLast != null) + { + let msNow = utl.ParseTimeToMs(td.Get(row, "DateTimeLocal")); + let msLast = utl.ParseTimeToMs(td.Get(rowLast, "DateTimeLocal")); + let msDiff = msNow - msLast; + + let MS_PER_10_MIN = 60 * 10 * 1000; + if (msDiff == MS_PER_10_MIN) + { + let sourceNow = td.GetRowMetaData(row)?.overlap?.resolved?.sourceByFamily?.location ?? null; + let sourceLast = td.GetRowMetaData(rowLast)?.overlap?.resolved?.sourceByFamily?.location ?? null; + + // Ignore coarse RT1-only positions here. GPS speed should be + // driven only by refined GPS-derived location sources. + if (sourceNow == null || sourceLast == null || sourceNow == "RT1" || sourceLast == "RT1") + { + rowLast = row; + return; + } + + let km = GreatCircle.distance(latLast, lngLast, lat, lng, "KM"); + + let uncertaintyNowKm = this.GetLocationSourceUncertaintyKm(sourceNow) ?? 0; + let uncertaintyLastKm = this.GetLocationSourceUncertaintyKm(sourceLast) ?? 0; + let combinedUncertaintyKm = uncertaintyNowKm + uncertaintyLastKm; + + // Ignore apparent movement that fits within the precision envelope + // of the two endpoint locations. + let effectiveKm = Math.max(0, km - combinedUncertaintyKm); + let kph = effectiveKm * 6; + + kphRawList[idx] = kph; + } + } + } + + rowLast = row; + }, true); + + let mphList = new Array(td.Length()).fill(null); + let kphList = new Array(td.Length()).fill(null); + let row__idx = new Map(); + td.ForEach((row, idx) => { + row__idx.set(row, idx); + }); + + // Then smooth with a rolling median, which is more resistant to one-off + // spikes than the original mean-based smoothing. + for (let row = 0; row < td.Length(); ++row) + { + let kphCandidateList = []; + for (let idx = Math.max(0, row - 2); idx <= Math.min(td.Length() - 1, row + 2); ++idx) + { + let kph = kphRawList[idx]; + if (kph != null && Number.isFinite(kph)) + { + kphCandidateList.push(kph); + } + } + + if (kphCandidateList.length >= 2) + { + let kph = this.GetMedian(kphCandidateList); + kphList[row] = Math.round(kph); + mphList[row] = Math.round(kph * 0.621371); + } + } + + td.AppendGeneratedColumns([ + "GpsKPH", "GpsMPH" + ], (row) => { + let rowIdx = row__idx.get(row); + return [kphList[rowIdx], mphList[rowIdx]]; + }, true); + } + + GetLocationSourceUncertaintyKm(source) + { + switch (source) + { + // RT1 is only a coarse grid4 location. + case "RT1": return 80; + + // BT is based on grid6 refinement and is materially better than RT1, + // but still much coarser than explicit indexed lat/lng messages. + case "BT": return 4; + + // EBT and HRL are explicit indexed latitude/longitude refinements. + case "EBT": return 1.5; + case "HRL": return 0.5; + } + + return null; + } + + GetMedian(numList) + { + if (!numList?.length) + { + return null; + } + + let sorted = [...numList].sort((a, b) => a - b); + let mid = Math.floor(sorted.length / 2); + + if (sorted.length % 2) + { + return sorted[mid]; + } + + return (sorted[mid - 1] + sorted[mid]) / 2; + } +} diff --git a/js/WsprSearchResultDataTableColumnBuilderExpandedBasicTelemetry.js b/js/WsprSearchResultDataTableColumnBuilderExpandedBasicTelemetry.js new file mode 100644 index 0000000..4194688 --- /dev/null +++ b/js/WsprSearchResultDataTableColumnBuilderExpandedBasicTelemetry.js @@ -0,0 +1,150 @@ +/* +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 { CodecExpandedBasicTelemetry } from './CodecExpandedBasicTelemetry.js'; + + +export class ColumnBuilderExpandedBasicTelemetry +{ + constructor() + { + this.codecExpandedBasicTelemetry = new CodecExpandedBasicTelemetry(); + } + + MatchWindow(slotMsgList) + { + return this.HasAnyExpandedBasicTelemetry(slotMsgList); + } + + GetColNameList() + { + return [ + "EbtGpsValid", + "EbtLatitudeIdx", + "EbtLongitudeIdx", + "EbtTempF", + "EbtTempC", + "EbtVoltage", + "EbtAltFt", + "EbtAltM", + ]; + } + + GetColMetaDataList() + { + return [ + { rangeMin: 0, rangeMax: 1 }, + { rangeMin: 0, rangeMax: 15 }, + { rangeMin: 0, rangeMax: 35 }, + { rangeMin: -60, rangeMax: 70 }, + { rangeMin: -51, rangeMax: 21 }, + { rangeMin: 1.8, rangeMax: 7.0 }, + { rangeMin: 0, rangeMax: 120000 }, + { rangeMin: 0, rangeMax: 36576 }, + ]; + } + + GetValListForWindow(slotMsgList) + { + let decoded = this.GetLatestExpandedBasicTelemetry(slotMsgList); + if (!decoded) + { + return new Array(this.GetColNameList().length).fill(null); + } + + return [ + decoded.gpsValid, + decoded.latitudeIdx, + decoded.longitudeIdx, + decoded.tempF, + decoded.tempC, + decoded.voltage, + decoded.altFt, + decoded.altM, + ]; + } + + GetLatestExpandedBasicTelemetry(slotMsgList) + { + if (!Array.isArray(slotMsgList) || slotMsgList.length == 0) + { + return null; + } + + for (let slot = slotMsgList.length - 1; slot >= 1; --slot) + { + let decoded = this.DecodeExpandedBasicTelemetryMsg(slotMsgList[slot]); + if (decoded) + { + return decoded; + } + } + + return null; + } + + HasAnyExpandedBasicTelemetry(slotMsgList) + { + if (!Array.isArray(slotMsgList) || slotMsgList.length == 0) + { + return false; + } + + for (let slot = slotMsgList.length - 1; slot >= 1; --slot) + { + if (this.IsExpandedBasicTelemetryMsg(slotMsgList[slot])) + { + return true; + } + } + + return false; + } + + IsExpandedBasicTelemetryMsg(msg) + { + if (!msg?.IsTelemetryExtended?.()) + { + return false; + } + + let codec = msg.GetCodec?.(); + + return this.codecExpandedBasicTelemetry.IsCodecExpandedBasicTelemetry(codec); + } + + DecodeExpandedBasicTelemetryMsg(msg) + { + if (!this.IsExpandedBasicTelemetryMsg(msg)) + { + return null; + } + + let codec = msg.GetCodec?.(); + + let tempF = codec.GetTempF(); + let tempC = Math.round((tempF - 32) * 5 / 9); + let voltage = codec.GetVoltageV(); + let gpsValid = codec.GetGpsValidBool(); + let altFt = codec.GetAltitudeFt(); + let altM = Math.round(altFt / 3.28084); + let latitudeIdx = codec.GetLatitudeIdx(); + let longitudeIdx = codec.GetLongitudeIdx(); + + return { + tempF, + tempC, + voltage, + gpsValid, + latitudeIdx, + longitudeIdx, + altFt, + altM, + }; + } +} diff --git a/js/WsprSearchResultDataTableColumnBuilderHeartbeat.js b/js/WsprSearchResultDataTableColumnBuilderHeartbeat.js new file mode 100644 index 0000000..d538dc2 --- /dev/null +++ b/js/WsprSearchResultDataTableColumnBuilderHeartbeat.js @@ -0,0 +1,116 @@ +/* +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 { CodecHeartbeat } from './CodecHeartbeat.js'; + + +export class ColumnBuilderHeartbeat +{ + constructor() + { + this.codecHeartbeat = new CodecHeartbeat(); + } + + MatchWindow(slotMsgList) + { + return this.HasAnyHeartbeat(slotMsgList); + } + + GetColNameList() + { + return [ + "TxFreqHzIdx", + "UptimeMinutes", + "GpsLockType", + "GpsTryLockSeconds", + "GpsSatsInViewCount", + ]; + } + + GetColMetaDataList() + { + return [ + { rangeMin: 0, rangeMax: 200 }, + { rangeMin: 0, rangeMax: 1440 }, + { rangeMin: 0, rangeMax: 2 }, + { rangeMin: 0, rangeMax: 1200 }, + { rangeMin: 0, rangeMax: 50 }, + ]; + } + + GetValListForWindow(slotMsgList) + { + let msg = this.GetLatestHeartbeat(slotMsgList); + if (!msg) + { + return [null, null, null, null, null]; + } + + let codec = msg.GetCodec(); + + return [ + codec.GetTxFreqHzIdx(), + codec.GetUptimeMinutes(), + codec.GetGpsLockTypeEnum(), + codec.GetGpsTryLockSeconds(), + codec.GetGpsSatsInViewCount(), + ]; + } + + GetLatestHeartbeat(slotMsgList) + { + if (!Array.isArray(slotMsgList) || slotMsgList.length == 0) + { + return null; + } + + for (let slot = slotMsgList.length - 1; slot >= 0; --slot) + { + let msg = slotMsgList[slot]; + if (!msg?.IsTelemetryExtended?.()) + { + continue; + } + + if (this.codecHeartbeat.IsCodecHeartbeat(msg.GetCodec?.())) + { + return msg; + } + } + + return null; + } + + HasAnyHeartbeat(slotMsgList) + { + if (!Array.isArray(slotMsgList) || slotMsgList.length == 0) + { + return false; + } + + for (let slot = slotMsgList.length - 1; slot >= 0; --slot) + { + if (this.IsHeartbeatMsg(slotMsgList[slot])) + { + return true; + } + } + + return false; + } + + IsHeartbeatMsg(msg) + { + if (!msg?.IsTelemetryExtended?.()) + { + return false; + } + + return this.codecHeartbeat.IsCodecHeartbeat(msg.GetCodec?.()); + } +} diff --git a/js/WsprSearchResultDataTableColumnBuilderHighResLocation.js b/js/WsprSearchResultDataTableColumnBuilderHighResLocation.js new file mode 100644 index 0000000..eded32d --- /dev/null +++ b/js/WsprSearchResultDataTableColumnBuilderHighResLocation.js @@ -0,0 +1,123 @@ +/* +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 { CodecHighResLocation } from './CodecHighResLocation.js'; + + +export class ColumnBuilderHighResLocation +{ + constructor() + { + this.codecHighResLocation = new CodecHighResLocation(); + } + + MatchWindow(slotMsgList) + { + return this.HasAnyHighResLocation(slotMsgList); + } + + GetColNameList() + { + return [ + "HiResReference", + "HiResLatitudeIdx", + "HiResLongitudeIdx", + ]; + } + + GetColMetaDataList() + { + return [ + { rangeMin: 0, rangeMax: 1 }, + { rangeMin: 0, rangeMax: 12352 }, + { rangeMin: 0, rangeMax: 24617 }, + ]; + } + + GetValListForWindow(slotMsgList) + { + let loc = this.GetLatestHighResLocation(slotMsgList); + + if (!loc) + { + return [null, null, null]; + } + + return [loc.referenceEnum, loc.latitudeIdx, loc.longitudeIdx]; + } + + GetLatestHighResLocation(slotMsgList) + { + if (!Array.isArray(slotMsgList) || slotMsgList.length == 0) + { + return null; + } + + for (let slot = slotMsgList.length - 1; slot >= 1; --slot) + { + let msg = slotMsgList[slot]; + let loc = this.DecodeLocationFromMsg(msg); + + if (loc) + { + return loc; + } + } + + return null; + } + + HasAnyHighResLocation(slotMsgList) + { + if (!Array.isArray(slotMsgList) || slotMsgList.length == 0) + { + return false; + } + + for (let slot = slotMsgList.length - 1; slot >= 1; --slot) + { + if (this.IsHighResLocationMsg(slotMsgList[slot])) + { + return true; + } + } + + return false; + } + + IsHighResLocationMsg(msg) + { + if (!msg?.IsTelemetryExtended?.()) + { + return false; + } + + let codec = msg.GetCodec?.(); + + return this.codecHighResLocation.IsCodecHighResLocation(codec); + } + + DecodeLocationFromMsg(msg) + { + if (!this.IsHighResLocationMsg(msg)) + { + return null; + } + + let codec = msg.GetCodec?.(); + + let referenceEnum = codec.GetReferenceEnum(); + let latitudeIdx = codec.GetLatitudeIdx(); + let longitudeIdx = codec.GetLongitudeIdx(); + return { + referenceEnum, + latitudeIdx, + longitudeIdx, + }; + } +} diff --git a/js/WsprSearchResultDataTableColumnBuilderRegularType1.js b/js/WsprSearchResultDataTableColumnBuilderRegularType1.js new file mode 100644 index 0000000..dc5127a --- /dev/null +++ b/js/WsprSearchResultDataTableColumnBuilderRegularType1.js @@ -0,0 +1,58 @@ +/* +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 { WSPREncoded } from '/js/WSPREncoded.js'; + + +export class ColumnBuilderRegularType1 +{ + Match(msg) + { + return msg.IsRegular(); + } + + GetColNameList() + { + return [ + "RegCall", + "RegGrid", + "RegPower", + "RegLat", + "RegLng", + ]; + } + + GetColMetaDataList() + { + return [ + {}, + {}, + {}, + {}, + {}, + ]; + } + + GetValList(msg) + { + let lat = null; + let lng = null; + if (msg.fields.grid4) + { + [lat, lng] = WSPREncoded.DecodeMaidenheadToDeg(msg.fields.grid4); + } + + return [ + msg.fields.callsign, + msg.fields.grid4, + msg.fields.powerDbm, + lat, + lng, + ]; + } +} diff --git a/js/WsprSearchResultDataTableColumnBuilderTelemetryBasic.js b/js/WsprSearchResultDataTableColumnBuilderTelemetryBasic.js new file mode 100644 index 0000000..b5b6850 --- /dev/null +++ b/js/WsprSearchResultDataTableColumnBuilderTelemetryBasic.js @@ -0,0 +1,70 @@ +/* +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'; + + +export class ColumnBuilderTelemetryBasic +{ + Match(msg) + { + return msg.IsTelemetryBasic(); + } + + GetColNameList() + { + return [ + "BtGpsValid", + "BtGrid56", + "BtTempC", + "BtTempF", + "BtVoltage", + "BtAltM", + "BtAltFt", + "BtKPH", + "BtMPH", + ]; + } + + GetColMetaDataList() + { + return [ + {}, + {}, + { rangeMin: -50, rangeMax: 39, }, + { rangeMin: utl.CtoF_Round(-50), rangeMax: utl.CtoF_Round(39), }, + { rangeMin: 3, rangeMax: 4.95, }, + { rangeMin: 0, rangeMax: 21340, }, + { rangeMin: 0, rangeMax: utl.MtoFt_Round(21340), }, + { rangeMin: 0, rangeMax: utl.KnotsToKph_Round(82), }, + { rangeMin: 0, rangeMax: utl.KnotsToMph_Round(82), }, + ]; + } + + GetValList(msg) + { + let gpsValid = msg.decodeDetails.basic.gpsIsValid; + let grid56 = msg.decodeDetails.basic.grid56; + let kph = utl.KnotsToKph_Round(msg.decodeDetails.basic.speedKnots); + let altFt = utl.MtoFt_Round(msg.decodeDetails.basic.altitudeMeters); + let tempF = utl.CtoF_Round(msg.decodeDetails.basic.temperatureCelsius); + let mph = utl.KnotsToMph_Round(msg.decodeDetails.basic.speedKnots); + + return [ + gpsValid, + grid56, + msg.decodeDetails.basic.temperatureCelsius, + tempF, + msg.decodeDetails.basic.voltageVolts, + msg.decodeDetails.basic.altitudeMeters, + altFt, + kph, + mph, + ]; + } +} diff --git a/js/WsprSearchResultDataTableColumnBuilderTelemetryExtendedUserOrVendorDefined.js b/js/WsprSearchResultDataTableColumnBuilderTelemetryExtendedUserOrVendorDefined.js new file mode 100644 index 0000000..7edde66 --- /dev/null +++ b/js/WsprSearchResultDataTableColumnBuilderTelemetryExtendedUserOrVendorDefined.js @@ -0,0 +1,80 @@ +/* +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.) +*/ + +export class ColumnBuilderTelemetryExtendedUserOrVendorDefined +{ + constructor(slot, codecMaker, type) + { + this.slot = slot; + this.codec = codecMaker.GetCodecInstance(); + this.type = type; + + this.colNameList = []; + this.colNameList.push(`slot${this.slot}.${this.type}.EncMsg`); + + for (let field of this.codec.GetFieldList()) + { + let colName = `slot${this.slot}.${this.type}.${field.name}${field.unit}`; + + this.colNameList.push(colName); + } + } + + Match(msg) + { + let typeCorrect = this.type == "ud" ? + msg.IsExtendedTelemetryUserDefined() : + msg.IsExtendedTelemetryVendorDefined(); + + let retVal = typeCorrect && msg.GetCodec().GetHdrSlotEnum() == this.slot; + + return retVal; + } + + GetColNameList() + { + return this.colNameList; + } + + GetColMetaDataList() + { + let metaDataList = []; + + metaDataList.push({}); + + for (let field of this.codec.GetFieldList()) + { + let metaData = { + rangeMin: this.codec[`Get${field.name}${field.unit}LowValue`](), + rangeMax: this.codec[`Get${field.name}${field.unit}HighValue`](), + }; + + metaDataList.push(metaData); + } + + return metaDataList; + } + + GetValList(msg) + { + let codec = msg.GetCodec(); + + let valList = []; + + valList.push(`${msg.fields.callsign} ${msg.fields.grid4} ${msg.fields.powerDbm}`); + + for (let field of codec.GetFieldList()) + { + let val = codec[`Get${field.name}${field.unit}`](); + + valList.push(val); + } + + return valList; + } +} diff --git a/js/WsprSearchUi.js b/js/WsprSearchUi.js new file mode 100644 index 0000000..b98070e --- /dev/null +++ b/js/WsprSearchUi.js @@ -0,0 +1,170 @@ +/* +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 { Base } from './Base.js'; + +import { WsprSearch } from './WsprSearch.js'; + +import { PreLoadChartExternalResources } from './Chart.js'; +import { WsprSearchUiChartsController } from './WsprSearchUiChartsController.js'; +import { WsprSearchUiFlightStatsController } from './WsprSearchUiFlightStatsController.js'; +import { WsprSearchUiInputController } from './WsprSearchUiInputController.js'; +import { WsprSearchUiDataTableController } from './WsprSearchUiDataTableController.js'; +import { WsprSearchUiMapController } from './WsprSearchUiMapController.js'; +import { WsprSearchUiStatsSearchController } from './WsprSearchUiStatsSearchController.js'; +import { WsprSearchUiStatsFilterController } from './WsprSearchUiStatsFilterController.js'; +import { TabularData } from '../../../../js/TabularData.js'; + + +export class WsprSearchUi +extends Base +{ + constructor(cfg) + { + super(); + + this.cfg = cfg; + + PreLoadChartExternalResources(); + + // search + this.wsprSearch = new WsprSearch(); + this.wsprSearch.AddOnSearchCompleteEventHandler(() => { + this.OnSearchComplete(); + }) + + // ui input + this.uiInput = new WsprSearchUiInputController({ + container: this.cfg.searchInput, + helpLink: this.cfg.helpLink, + mapContainer: this.cfg.map, + }); + + // ui map + this.uiMap = new WsprSearchUiMapController({ + container: this.cfg.map, + }); + + // ui charts + this.uiCharts = new WsprSearchUiChartsController({ + container: this.cfg.charts, + wsprSearch: this.wsprSearch, + }); + + // ui flight stats + this.uiFlightStats = new WsprSearchUiFlightStatsController({ + container: this.cfg.flightStats, + }); + + // ui data table + this.uiDataTable = new WsprSearchUiDataTableController({ + container: this.cfg.dataTable, + wsprSearch: this.wsprSearch, + }); + + // ui stats + this.uiStatsSearch = new WsprSearchUiStatsSearchController({ + container: this.cfg.searchStats, + wsprSearch: this.wsprSearch, + }); + + this.uiStatsFilter = new WsprSearchUiStatsFilterController({ + container: this.cfg.filterStats, + wsprSearch: this.wsprSearch, + }); + + window.addEventListener("message", evt => { + if (evt?.data?.type == "JUMP_TO_DATA" && evt.data.ts) + { + this.Emit({ + type: "JUMP_TO_DATA", + ts: evt.data.ts, + }); + } + }); + + this.PrimeEmptyResults(); + } + + SetDebug(tf) + { + super.SetDebug(tf); + + this.t.SetCcGlobal(tf); + + this.wsprSearch.SetDebug(tf); + + this.uiInput.SetDebug(tf); + this.uiMap.SetDebug(tf); + this.uiCharts.SetDebug(tf); + this.uiDataTable.SetDebug(tf); + this.uiStatsSearch.SetDebug(tf); + this.uiStatsFilter.SetDebug(tf); + } + + OnEvent(evt) + { + switch (evt.type) { + case "SEARCH_REQUESTED": this.OnSearchRequest(evt); break; + } + } + + OnSearchRequest(evt) + { + this.t.Global().Reset(); + this.t.Reset(); + this.t.Event("WsprSearchUi::OnSearchRequest Callback Start"); + + this.wsprSearch.Search(evt.band, evt.channel, evt.callsign, evt.gte, evt.lte); + + if (evt.msgDefinitionUserDefinedList) + { + this.wsprSearch.SetMsgDefinitionUserDefinedList(evt.msgDefinitionUserDefinedList); + this.uiDataTable.SetMsgDefinitionUserDefinedList(evt.msgDefinitionUserDefinedList); + } + + if (evt.msgDefinitionVendorDefinedList) + { + this.wsprSearch.SetMsgDefinitionVendorDefinedList(evt.msgDefinitionVendorDefinedList); + this.uiDataTable.SetMsgDefinitionVendorDefinedList(evt.msgDefinitionVendorDefinedList); + } + + this.t.Event("WsprSearchUi::OnSearchRequest Callback End"); + } + + OnSearchComplete() + { + this.t.Event("WsprSearchUi::OnSearchComplete Callback Start"); + + this.Emit("SEARCH_COMPLETE"); + + let td = this.wsprSearch.GetDataTable(); + this.Emit({ + type: "DATA_TABLE_RAW_READY", + tabularDataReadOnly: td, + }); + + this.t.Event("WsprSearchUi::OnSearchComplete Callback End"); + + // this.t.Global().Report(`WsprSearchUi Global`) + } + + PrimeEmptyResults() + { + let td = new TabularData([[ + "DateTimeUtc", + "DateTimeLocal", + ]]); + + this.Emit({ + type: "DATA_TABLE_RAW_READY", + tabularDataReadOnly: td, + isPlaceholder: true, + }); + } +} diff --git a/js/WsprSearchUiChartsController.js b/js/WsprSearchUiChartsController.js new file mode 100644 index 0000000..503f945 --- /dev/null +++ b/js/WsprSearchUiChartsController.js @@ -0,0 +1,926 @@ +/* +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 { TabularData } from '../../../../js/TabularData.js'; +import { + ChartTimeSeries, + ChartTimeSeriesBar, + ChartHistogramBar, + ChartTimeSeriesTwoEqualSeriesOneLine, + ChartTimeSeriesTwoEqualSeriesOneLinePlus, + ChartScatterSeriesPicker, +} from './Chart.js'; +import { WSPREncoded } from '/js/WSPREncoded.js'; +import { GreatCircle } from '/js/GreatCircle.js'; + + +export class WsprSearchUiChartsController +extends Base +{ + constructor(cfg) + { + super(); + + this.cfg = cfg; + this.wsprSearch = this.cfg.wsprSearch || null; + + this.ok = this.cfg.container; + + if (this.ok) + { + this.ui = this.MakeUI(); + this.activeChartsUi = this.MakeChartsGrid(); + this.ui.appendChild(this.activeChartsUi); + this.cfg.container.appendChild(this.ui); + + ChartTimeSeries.PreLoadExternalResources(); + } + + this.plotQueueToken = 0; + this.renderChartsUi = this.activeChartsUi; + this.pendingChartsUi = null; + } + + SetDebug(tf) + { + super.SetDebug(tf); + + this.t.SetCcGlobal(tf); + } + + OnEvent(evt) + { + if (this.ok) + { + switch (evt.type) { + case "DATA_TABLE_RAW_READY": this.OnDataTableRawReady(evt); break; + } + } + } + + OnDataTableRawReady(evt) + { + this.t.Reset(); + this.t.Event(`WsprSearchUiChartsController::OnDataTableReady Start`); + + if (this.pendingChartsUi) + { + this.pendingChartsUi.remove(); + this.pendingChartsUi = null; + } + + this.ui.style.minHeight = `${Math.max(this.ui.offsetHeight, this.activeChartsUi?.offsetHeight || 0, 300)}px`; + this.renderChartsUi = this.MakeChartsGrid(); + this.renderChartsUi.style.position = "absolute"; + this.renderChartsUi.style.top = "0"; + this.renderChartsUi.style.left = "-20000px"; + this.renderChartsUi.style.visibility = "hidden"; + this.renderChartsUi.style.pointerEvents = "none"; + this.renderChartsUi.style.contain = "layout style paint"; + this.pendingChartsUi = this.renderChartsUi; + document.body.appendChild(this.renderChartsUi); + + // duplicate and enrich + let td = evt.tabularDataReadOnly; + this.plottedColSet = new Set(); + let plotJobList = []; + let enqueue = (fn) => { + plotJobList.push(fn); + }; + + // add standard charts + if (td.Idx("AltM") && td.Idx("AltFt")) + { + let defaultAltMaxM = 21340; + let defaultAltMaxFt = utl.MtoFt_Round(defaultAltMaxM); + let actualAltMaxM = this.GetFiniteColumnMax(td, "AltM"); + let actualAltMaxFt = this.GetFiniteColumnMax(td, "AltFt"); + + enqueue(() => this.PlotTwoSeriesOneLine(td, ["AltM", "AltFt"], [ + { min: 0, max: Math.max(defaultAltMaxM, actualAltMaxM ?? 0) }, + { min: 0, max: Math.max(defaultAltMaxFt, actualAltMaxFt ?? 0) }, + ])); + } + + if (td.Idx("MPH") && td.Idx("KPH") && + td.Idx("GpsMPH") && td.Idx("GpsKPH")) + { + enqueue(() => this.PlotTwoEqualSeriesPlus(td, ["KPH", "MPH", "GpsKPH", "GpsMPH"], 0, 290, 0, 180)); + } + + if (td.Idx("TempC") && td.Idx("TempF")) + { + enqueue(() => this.PlotTwoSeriesOneLine(td, ["TempC", "TempF"])); + } + + if (td.Idx("Voltage")) + { + enqueue(() => this.Plot(td, "Voltage")); + } + + if (td.Idx("UptimeMinutes")) + { + enqueue(() => this.Plot(td, "UptimeMinutes", 0, 1440)); + } + + if (td.Idx("GpsLockType")) + { + enqueue(() => this.Plot(td, "GpsLockType", 0, 2)); + } + + if (td.Idx("GpsTryLockSeconds")) + { + enqueue(() => this.Plot(td, "GpsTryLockSeconds", 0, null)); + } + + if (td.Idx("GpsSatsInViewCount")) + { + enqueue(() => this.Plot(td, "GpsSatsInViewCount", 0, 50)); + } + + if (td.Idx("SolAngle")) + { + enqueue(() => this.Plot(td, "SolAngle")); + } + + if (td.Idx("AltChgMpm") && td.Idx("AltChgFpm")) + { + enqueue(() => this.PlotTwoSeriesOneLine(td, ["AltChgMpm", "AltChgFpm"])); + } + + // plot dynamic columns + let headerList = td.GetHeaderList(); + + // plot UserDefined first + for (let slot = 0; slot < 5; ++slot) + { + let slotHeaderList = headerList.filter(str => str.startsWith(`slot${slot}.ud`)); + + for (let slotHeader of slotHeaderList) + { + if (slotHeader != `slot${slot}.ud.EncMsg`) + { + // let metadata drive this instead of auto-ranging? + enqueue(() => this.Plot(td, slotHeader, null, null)); + } + } + } + + // plot VendorDefined after + for (let slot = 0; slot < 5; ++slot) + { + let slotHeaderList = headerList.filter(str => str.startsWith(`slot${slot}.vd`)); + + for (let slotHeader of slotHeaderList) + { + if (slotHeader != `slot${slot}.vd.EncMsg`) + { + // let metadata drive this instead of auto-ranging? + enqueue(() => this.Plot(td, slotHeader, null, null)); + } + } + } + + // add summary charts + if (td.Idx("RxStationCount")) + { + enqueue(() => this.PlotBar(td, "RxStationCount", 0, null)); + } + + if (td.Idx("DateTimeLocal")) + { + enqueue(() => this.PlotSpotCountByDate(td)); + } + + if (td.Idx("WinFreqDrift")) + { + enqueue(() => this.PlotBar(td, "WinFreqDrift", null, null)); + } + + enqueue(() => this.PlotRxStationFingerprintFreqSpanHistogram()); + + if (td.Idx("Lat")) + { + enqueue(() => this.PlotRxStationDistanceHistogram(td)); + } + + enqueue(() => this.PlotScatterPicker(td, Array.from(this.plottedColSet).sort(), ["GpsMPH", "MPH"], ["AltFt"])); + + this.t.Event(`WsprSearchUiChartsController::OnDataTableReady End`); + + this.SchedulePlotJobs(plotJobList, () => { + if (!this.pendingChartsUi) + { + this.Emit({ + type: "CHARTS_RENDER_COMPLETE", + }); + return; + } + + if (this.activeChartsUi) + { + this.activeChartsUi.remove(); + } + + this.ui.appendChild(this.pendingChartsUi); + this.pendingChartsUi.style.position = ""; + this.pendingChartsUi.style.top = ""; + this.pendingChartsUi.style.left = ""; + this.pendingChartsUi.style.visibility = ""; + this.pendingChartsUi.style.pointerEvents = ""; + this.pendingChartsUi.style.contain = ""; + + this.activeChartsUi = this.pendingChartsUi; + this.pendingChartsUi = null; + this.renderChartsUi = this.activeChartsUi; + this.ui.style.minHeight = ""; + + this.Emit({ + type: "CHARTS_RENDER_COMPLETE", + }); + }); + } + + SchedulePlotJobs(plotJobList, onComplete) + { + let token = ++this.plotQueueToken; + let idx = 0; + let JOBS_PER_SLICE_MAX = 4; + let TIME_BUDGET_MS = 12; + + let runSlice = (deadline) => { + if (token != this.plotQueueToken) + { + return; + } + + let jobsRun = 0; + let sliceStart = performance.now(); + while (idx < plotJobList.length && jobsRun < JOBS_PER_SLICE_MAX) + { + let outOfIdleTime = false; + if (deadline && typeof deadline.timeRemaining == "function") + { + outOfIdleTime = deadline.timeRemaining() <= 2; + } + else + { + outOfIdleTime = (performance.now() - sliceStart) >= TIME_BUDGET_MS; + } + + if (jobsRun > 0 && outOfIdleTime) + { + break; + } + + plotJobList[idx++](); + ++jobsRun; + } + + if (idx >= plotJobList.length) + { + if (onComplete) + { + onComplete(); + } + return; + } + + this.#ScheduleNextPlotSlice(runSlice); + }; + + this.#ScheduleNextPlotSlice(runSlice); + } + + #ScheduleNextPlotSlice(runSlice) + { + if (window.requestIdleCallback) + { + window.requestIdleCallback(runSlice, { timeout: 100 }); + } + else + { + window.setTimeout(() => { + window.requestAnimationFrame(() => runSlice()); + }, 0); + } + } + + MakeUI() + { + let ui = document.createElement("div"); + + ui.style.boxSizing = "border-box"; + ui.style.width = "1210px"; + ui.style.position = "relative"; + + return ui; + } + + MakeChartsGrid() + { + let ui = document.createElement("div"); + + ui.style.boxSizing = "border-box"; + ui.style.width = "1210px"; + ui.style.display = "grid"; + ui.style.gridTemplateColumns = "1fr 1fr"; // two columns, equal spacing + ui.style.gap = '0.5vw'; + + return ui; + } + + // default to trying to use metadata, let parameter min/max override + Plot(td, colName, min, max) + { + this.AddPlottedColumns([colName]); + + let chart = new ChartTimeSeries(); + chart.SetDebug(this.debug); + this.renderChartsUi.appendChild(chart.GetUI()); + + + let minUse = undefined; + let maxUse = undefined; + + // look up metadata (if any) to use initially + let metaData = td.GetColMetaData(colName); + if (metaData) + { + minUse = metaData.rangeMin; + maxUse = metaData.rangeMax; + } + + // let parameters override. + // null is not the same as undefined. + // passing null is the same as letting the chart auto-range. + if (min !== undefined) { minUse = min; } + if (max !== undefined) { maxUse = max; } + + chart.PlotData({ + td: td, + + xAxisDetail: { + column: "DateTimeLocal", + }, + + yAxisMode: "one", + + yAxisDetailList: [ + { + column: colName, + min: minUse, + max: maxUse, + }, + ] + }); + }; + + PlotMulti(td, colNameList) + { + this.AddPlottedColumns(colNameList); + + let chart = new ChartTimeSeries(); + chart.SetDebug(this.debug); + this.renderChartsUi.appendChild(chart.GetUI()); + + let yAxisDetailList = []; + + for (const colName of colNameList) + { + let metaData = td.GetColMetaData(colName); + + yAxisDetailList.push({ + column: colName, + min: metaData.rangeMin, + max: metaData.rangeMax, + }); + } + + chart.PlotData({ + td: td, + + xAxisDetail: { + column: "DateTimeLocal", + }, + + yAxisDetailList, + }); + }; + + PlotTwoSeriesOneLine(td, colNameList, rangeOverrideList = []) + { + this.AddPlottedColumns(colNameList); + + let chart = new ChartTimeSeriesTwoEqualSeriesOneLine(); + chart.SetDebug(this.debug); + this.renderChartsUi.appendChild(chart.GetUI()); + + let yAxisDetailList = []; + + for (let idx = 0; idx < colNameList.length; ++idx) + { + let colName = colNameList[idx]; + let metaData = td.GetColMetaData(colName); + let rangeOverride = rangeOverrideList[idx] ?? {}; + + yAxisDetailList.push({ + column: colName, + min: rangeOverride.min ?? metaData.rangeMin, + max: rangeOverride.max ?? metaData.rangeMax, + }); + } + + chart.PlotData({ + td: td, + + xAxisDetail: { + column: "DateTimeLocal", + }, + + yAxisDetailList, + }); + }; + + PlotTwoEqualSeriesPlus(td, colNameList, minExtra0, maxExtra0, minExtra1, maxExtra1) + { + this.AddPlottedColumns(colNameList); + + let chart = new ChartTimeSeriesTwoEqualSeriesOneLinePlus(); + chart.SetDebug(this.debug); + this.renderChartsUi.appendChild(chart.GetUI()); + + let yAxisDetailList = []; + + for (const colName of colNameList) + { + let metaData = td.GetColMetaData(colName); + + yAxisDetailList.push({ + column: colName, + min: metaData.rangeMin, + max: metaData.rangeMax, + }); + } + + // force the min/max of the 2 additional series + yAxisDetailList[2].min = minExtra0; + yAxisDetailList[2].max = maxExtra0; + + yAxisDetailList[3].min = minExtra1; + yAxisDetailList[3].max = maxExtra1; + + chart.PlotData({ + td: td, + + xAxisDetail: { + column: "DateTimeLocal", + }, + + yAxisDetailList, + }); + }; + + PlotScatterPicker(td, colNameList, preferredXSeriesList, preferredYSeriesList) + { + if (!colNameList || colNameList.length < 1) + { + return; + } + + let chart = new ChartScatterSeriesPicker(); + chart.SetDebug(this.debug); + this.renderChartsUi.appendChild(chart.GetUI()); + + chart.PlotData({ + td, + colNameList, + preferredXSeriesList, + preferredYSeriesList, + }); + }; + + GetFiniteColumnMax(td, colName) + { + if (td.Idx(colName) == undefined) + { + return null; + } + + let max = null; + td.ForEach(row => { + let val = Number(td.Get(row, colName)); + if (Number.isFinite(val)) + { + max = max == null ? val : Math.max(max, val); + } + }); + + return max; + } + + AddPlottedColumns(colNameList) + { + for (const colName of colNameList) + { + this.plottedColSet.add(colName); + } + } + + PlotSpotCountByDate(td) + { + let tdRxCount = this.MakeSpotCountByDateTd(td); + + if (tdRxCount.Length() > 0) + { + this.PlotBar(tdRxCount, "SpotCountByDate", 0, null); + } + } + + MakeSpotCountByDateTd(td) + { + let countByDate = new Map(); + + let dateTimeList = td.ExtractDataOnly(["DateTimeLocal"]); + for (const row of dateTimeList) + { + let dt = row[0]; + if (dt == undefined || dt == null || dt === "") + { + continue; + } + + let dateOnly = String(dt).substring(0, 10); + if (dateOnly.length != 10) + { + continue; + } + + let cur = countByDate.get(dateOnly) || 0; + countByDate.set(dateOnly, cur + 1); + } + + let dataTable = [["DateTimeLocal", "SpotCountByDate"]]; + for (const [dateOnly, count] of countByDate.entries()) + { + dataTable.push([dateOnly, count]); + } + + return new TabularData(dataTable); + } + + PlotBar(td, colName, min, max) + { + this.AddPlottedColumns([colName]); + + let chart = new ChartTimeSeriesBar(); + chart.SetDebug(this.debug); + this.renderChartsUi.appendChild(chart.GetUI()); + + let minUse = undefined; + let maxUse = undefined; + + let metaData = td.GetColMetaData(colName); + if (metaData) + { + minUse = metaData.rangeMin; + maxUse = metaData.rangeMax; + } + + if (min !== undefined) { minUse = min; } + if (max !== undefined) { maxUse = max; } + + chart.PlotData({ + td: td, + + xAxisDetail: { + column: "DateTimeLocal", + }, + + yAxisMode: "one", + + yAxisDetailList: [ + { + column: colName, + min: minUse, + max: maxUse, + }, + ] + }); + }; + + PlotRxStationDistanceHistogram(td) + { + let hist = this.MakeRxStationDistanceHistogram(td, 25); + if (!hist) + { + return; + } + + let chart = new ChartHistogramBar(); + chart.SetDebug(this.debug); + this.renderChartsUi.appendChild(chart.GetUI()); + + chart.PlotData(hist); + } + + PlotRxStationFingerprintFreqSpanHistogram() + { + let hist = this.MakeRxStationFingerprintFreqSpanHistogram(); + if (!hist) + { + return; + } + + let chart = new ChartHistogramBar(); + chart.SetDebug(this.debug); + this.renderChartsUi.appendChild(chart.GetUI()); + + chart.PlotData(hist); + } + + MakeRxStationDistanceHistogram(td, bucketCount) + { + const MAX_DISTANCE_KM = 20037.5; // half Earth's circumference + + if (!bucketCount || bucketCount < 1) + { + return null; + } + + let bucketSize = MAX_DISTANCE_KM / bucketCount; + let bucketCountList = new Array(bucketCount).fill(0); + + td.ForEach(row => { + let txLat = null; + let txLng = null; + let lat = td.Idx("Lat") != undefined ? td.Get(row, "Lat") : null; + let lng = td.Idx("Lng") != undefined ? td.Get(row, "Lng") : null; + + if (lat != null && lng != null) + { + txLat = Number(lat); + txLng = Number(lng); + } + else + { + return; + } + + if (!Number.isFinite(txLat) || !Number.isFinite(txLng)) + { + return; + } + + // Build superset of receiving stations for this row/window. + let stationCallToGrid = new Map(); + let slotMsgList = td.GetRowMetaData(row)?.slotMsgList || []; + + for (const msg of slotMsgList) + { + if (!msg || !Array.isArray(msg.rxRecordList)) + { + continue; + } + + for (const rxRecord of msg.rxRecordList) + { + let rxCall = rxRecord?.rxCallsign; + let rxGrid = rxRecord?.rxGrid; + if (!rxCall || !rxGrid) + { + continue; + } + + if (!stationCallToGrid.has(rxCall)) + { + stationCallToGrid.set(rxCall, rxGrid); + } + } + } + + // Calculate one distance per station in superset. + for (const rxGrid of stationCallToGrid.values()) + { + let rxLat = null; + let rxLng = null; + try + { + [rxLat, rxLng] = WSPREncoded.DecodeMaidenheadToDeg(rxGrid); + } + catch + { + continue; + } + + if (!Number.isFinite(rxLat) || !Number.isFinite(rxLng)) + { + continue; + } + + let distKm = GreatCircle.distance(txLat, txLng, rxLat, rxLng, "KM"); + if (!Number.isFinite(distKm)) + { + continue; + } + + let distUse = Math.max(0, Math.min(MAX_DISTANCE_KM, distKm)); + let bucketIdx = Math.floor(distUse / bucketSize); + bucketIdx = Math.max(0, Math.min(bucketCount - 1, bucketIdx)); + + bucketCountList[bucketIdx] += 1; + } + }); + + let bucketLabelList = []; + for (let i = 0; i < bucketCount; ++i) + { + let high = (i + 1) * bucketSize; + if (i == bucketCount - 1) + { + high = MAX_DISTANCE_KM; + } + + let highKm = Math.round(high); + let highMi = Math.round(high * 0.621371); + bucketLabelList.push(`${utl.Commas(highKm)}km / ${utl.Commas(highMi)}mi`); + } + + return { + bucketLabelList, + bucketCountList, + grid: { + top: 30, + left: 52, + right: 18, + bottom: 82, + }, + xAxisNameGap: 58, + xAxisLabelRotate: 45, + xAxisLabelMargin: 8, + yAxisNameGap: 14, + yAxisName: " Histogram RX Distance", + }; + } + + MakeRxStationFingerprintFreqSpanHistogram() + { + if (!this.wsprSearch?.time__windowData) + { + return null; + } + + let rxCall__freqStats = new Map(); + + let addRxRecord = (rxRecord) => { + let rxCall = rxRecord?.rxCallsign; + let freq = Number(rxRecord?.frequency); + if (!rxCall || !Number.isFinite(freq)) + { + return; + } + + if (!rxCall__freqStats.has(rxCall)) + { + rxCall__freqStats.set(rxCall, { + min: freq, + max: freq, + }); + return; + } + + let stats = rxCall__freqStats.get(rxCall); + if (freq < stats.min) { stats.min = freq; } + if (freq > stats.max) { stats.max = freq; } + }; + + for (const [time, windowData] of this.wsprSearch.time__windowData) + { + let referenceAudit = windowData?.fingerprintingData?.referenceAudit; + if (!referenceAudit?.slotAuditList) + { + continue; + } + + for (let slot = 0; slot < referenceAudit.slotAuditList.length; ++slot) + { + if (slot == referenceAudit.referenceSlot) + { + continue; + } + + let slotAudit = referenceAudit.slotAuditList[slot]; + if (!slotAudit?.msgAuditList?.length || !slotAudit?.msgMatchList?.length) + { + continue; + } + + let msgMatchSet = new Set(slotAudit.msgMatchList); + for (const msgAudit of slotAudit.msgAuditList) + { + if (!msgMatchSet.has(msgAudit.msg)) + { + continue; + } + + for (const rxCallMatch of msgAudit.rxCallMatchList || []) + { + for (const rxRecord of rxCallMatch.rxRecordListA || []) + { + addRxRecord(rxRecord); + } + for (const rxRecord of rxCallMatch.rxRecordListB || []) + { + addRxRecord(rxRecord); + } + } + } + } + } + + if (rxCall__freqStats.size == 0) + { + return null; + } + + let spanList = []; + let maxSpan = 0; + for (const [rxCall, stats] of rxCall__freqStats) + { + let span = Math.abs(stats.max - stats.min); + span = Math.round(span); + spanList.push(span); + if (span > maxSpan) + { + maxSpan = span; + } + } + + const MAX_BUCKET_START = 150; + + let bucketSpecList = []; + for (let span = 0; span <= 30; ++span) + { + bucketSpecList.push({ + min: span, + max: span, + label: `${span}`, + }); + } + for (let start = 31; start <= MAX_BUCKET_START - 1; start += 5) + { + let end = Math.min(start + 4, MAX_BUCKET_START - 1); + bucketSpecList.push({ + min: start, + max: end, + label: `${start}`, + }); + } + bucketSpecList.push({ + min: MAX_BUCKET_START - 1, + max: MAX_BUCKET_START - 1, + label: `${MAX_BUCKET_START - 1}`, + }); + bucketSpecList.push({ + min: MAX_BUCKET_START, + max: Number.POSITIVE_INFINITY, + label: `${MAX_BUCKET_START}+`, + }); + + let bucketLabelList = bucketSpecList.map(spec => spec.label); + let bucketCountList = new Array(bucketSpecList.length).fill(0); + for (const span of spanList) + { + let idxBucket = bucketSpecList.findIndex(spec => span >= spec.min && span <= spec.max); + if (idxBucket != -1) + { + bucketCountList[idxBucket] += 1; + } + } + + return { + bucketLabelList, + bucketCountList, + xAxisName: "Hz", + grid: { + top: 30, + left: 34, + right: 14, + bottom: 40, + }, + xAxisNameGap: 28, + xAxisLabelRotate: 30, + xAxisLabelMargin: 10, + yAxisNameGap: 10, + yAxisName: " Histogram RX Freq Diff", + }; + } +} diff --git a/js/WsprSearchUiDataTableColumnOrder.js b/js/WsprSearchUiDataTableColumnOrder.js new file mode 100644 index 0000000..288725a --- /dev/null +++ b/js/WsprSearchUiDataTableColumnOrder.js @@ -0,0 +1,35 @@ +/* +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.) +*/ + +export class WsprSearchUiDataTableColumnOrder +{ + static GetPriorityColList() + { + return [ + "Pro", + "DateTimeUtc", "DateTimeLocal", + "RegSeen", "EncSeen", + "RegCall", "RegGrid", "RegPower", "RegLat", "RegLng", + "BtGpsValid", "BtGrid6", "BtGrid56", "BtLat", "BtLng", "BtVoltage", "BtTempF", "BtAltFt", "BtMPH", "BtTempC", "BtAltM", "BtKPH", + "Lat", "Lng", "Voltage", + "TempF", "AltFt", "AltChgFpm", + "MPH", "GpsMPH", "DistMi", + "TempC", "AltM", "AltChgMpm", + "KPH", "GpsKPH", "DistKm", + "SolAngle", "RxStationCount", "WinFreqDrift", + "UptimeMinutes", "GpsLockType", "GpsTryLockSeconds", "GpsSatsInViewCount", "TxFreqHzIdx", "TxFreqMhz", + "EbtGpsValid", "EbtVoltage", "EbtLat", "EbtLng", "EbtLatitudeIdx", "EbtLongitudeIdx", "EbtTempF", "EbtAltFt", "EbtTempC", "EbtAltM", + "HiResReference", "HiResLat", "HiResLng", "HiResLatitudeIdx", "HiResLongitudeIdx", + ]; + } + + static Apply(td) + { + td.PrioritizeColumnOrder(this.GetPriorityColList()); + } +} diff --git a/js/WsprSearchUiDataTableController.js b/js/WsprSearchUiDataTableController.js new file mode 100644 index 0000000..8609f75 --- /dev/null +++ b/js/WsprSearchUiDataTableController.js @@ -0,0 +1,1882 @@ +/* +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 { CSSDynamic } from './CSSDynamic.js'; +import { TabularData } from '../../../../js/TabularData.js'; +import { WSPREncoded } from '/js/WSPREncoded.js'; +import { WsprSearchUiDataTableRowProController } from './WsprSearchUiDataTableRowProController.js'; +import { WsprSearchUiDataTableVisibility } from './WsprSearchUiDataTableVisibility.js'; +import { WsprSearchUiDataTableColumnOrder } from './WsprSearchUiDataTableColumnOrder.js'; + + +export class WsprSearchUiDataTableController +extends Base +{ + static DETAILS_COOKIE_NAME = "wsprSearchDataTableControlsOpen"; + static UI_UPGRADE_VERSION_KEY = "wsprSearchDataTableUiUpgradeVersion"; + static UI_UPGRADE_VERSION = 2; + + constructor(cfg) + { + super(); + + this.cfg = cfg; + this.wsprSearch = this.cfg.wsprSearch || null; + + this.ok = this.cfg.container; + + this.msgDefinitionUserDefinedList = new Array(5).fill(""); + this.msgDefinitionVendorDefinedList = new Array(5).fill(""); + + // initialize display/data state before controls are created + // so persisted control callbacks do not get overwritten later. + this.table = document.createElement('table'); + this.band = "20m"; + this.channel = ""; + this.channelPadded = "000"; + this.callsign = "unset"; + this.gte = ""; + this.lte = ""; + this.td = new TabularData(); + this.tdSource = null; + this.showProColumn = false; + this.renderTableToken = 0; + this.pendingRenderAfterCharts = null; + this.pendingRenderAfterChartsFallbackId = null; + this.pendingJumpToDataTs = null; + + if (this.ok) + { + this.ui = this.MakeUI(); + this.cfg.container.appendChild(this.ui); + } + } + + SetDebug(tf) + { + super.SetDebug(tf); + + this.t.SetCcGlobal(tf); + } + + OnEvent(evt) + { + if (this.ok) + { + switch (evt.type) { + case "SEARCH_REQUESTED": this.#OnSearchRequest(evt); break; + case "DATA_TABLE_RAW_READY": this.OnDataTableRawReady(evt); break; + case "CHARTS_RENDER_COMPLETE": this.#OnChartsRenderComplete(); break; + case "JUMP_TO_DATA": this.#OnJumpToData(evt); break; + } + } + } + + #OnSearchRequest(evt) + { + this.band = evt.band; + this.channel = evt.channel; + this.channelPadded = ("000" + evt.channel).slice(-3); + this.callsign = evt.callsign; + this.gte = evt.gte; + this.lte = evt.lteRaw; + + this.#UpdateDownloadAndCopyLinks(); + } + + OnDataTableRawReady(evt) + { + this.t.Reset(); + let t1 = this.t.Event(`WsprSearchUiDataTableController::OnDataTableRawReady Start`); + + // clear existing child nodes + this.cfg.container.innerHTML = ""; + + this.tdSource = evt.tabularDataReadOnly; + + this.cfg.container.appendChild(this.ui); + + let token = ++this.renderTableToken; + let run = () => { + if (token != this.renderTableToken) + { + return; + } + + this.#RenderDataTable(); + + let t2 = this.t.Event(`WsprSearchUiDataTableController::OnDataTableRawReady End`); + + let stats = this.wsprSearch?.GetStats?.(); + if (stats?.processing) + { + stats.processing.uiRenderMs = Math.round(t2 - t1); + } + }; + + this.pendingRenderAfterCharts = { + token, + run, + }; + + if (this.pendingRenderAfterChartsFallbackId != null) + { + window.clearTimeout(this.pendingRenderAfterChartsFallbackId); + } + this.pendingRenderAfterChartsFallbackId = window.setTimeout(() => { + this.#OnChartsRenderComplete(); + }, 2000); + } + + #OnChartsRenderComplete() + { + if (!this.pendingRenderAfterCharts) + { + return; + } + + if (this.pendingRenderAfterChartsFallbackId != null) + { + window.clearTimeout(this.pendingRenderAfterChartsFallbackId); + this.pendingRenderAfterChartsFallbackId = null; + } + + let { token, run } = this.pendingRenderAfterCharts; + this.pendingRenderAfterCharts = null; + + let scheduleRun = () => { + if (token != this.renderTableToken) + { + return; + } + + if (window.requestIdleCallback) + { + window.requestIdleCallback(() => { + window.requestAnimationFrame(run); + }, { timeout: 500 }); + } + else + { + window.setTimeout(() => { + window.requestAnimationFrame(run); + }, 0); + } + }; + + window.requestAnimationFrame(scheduleRun); + } + + #OnJumpToData(evt) + { + this.pendingJumpToDataTs = evt.ts ?? null; + this.#TryHandlePendingJumpToData(); + } + + SetMsgDefinitionUserDefinedList(msgDefinitionUserDefinedList) + { + this.msgDefinitionUserDefinedList = msgDefinitionUserDefinedList; + } + + SetMsgDefinitionVendorDefinedList(msgDefinitionVendorDefinedList) + { + this.msgDefinitionVendorDefinedList = msgDefinitionVendorDefinedList; + } + + ModifyTableContentsForDisplay(td) + { + this.FormatNumbers(td); + this.Linkify(td); + this.LinkifyUserOrVendorDefined(td, "ud"); + this.LinkifyUserOrVendorDefined(td, "vd"); + this.StyleOverlapData(td); + this.AddProColumnForDisplay(td); + this.PrioritizeColumnOrder(td); + } + + AddProColumnForDisplay(td) + { + td.PrependGeneratedColumns([ + "Pro" + ], row => { + return [`🧠`]; + }); + } + + FormatNumbers(td) + { + let colList = td.GetHeaderList(); + let colInfoList = []; + + for (const col of colList) + { + let idx = td.Idx(col); + if (idx == undefined) + { + continue; + } + + let useDecimals = false; + for (let rowIdx = 1; rowIdx < td.dataTable.length; ++rowIdx) + { + let val = td.dataTable[rowIdx][idx]; + + if (typeof val == "number") + { + if (val.toString().indexOf('.') != -1) + { + useDecimals = true; + break; + } + } + } + + let decimalPlaceLen = 3; + if (col == "Voltage" || col == "BtVoltage" || col == "EbtVoltage") + { + decimalPlaceLen = 2; + } + else if (col == "Lat" || col == "Lng" || + col == "RegLat" || col == "RegLng" || + col == "BtLat" || col == "BtLng" || + col == "EbtLat" || col == "EbtLng" || + col == "HiResLat" || col == "HiResLng") + { + decimalPlaceLen = 6; + } + else if (col == "TxFreqMhz") + { + decimalPlaceLen = 6; + } + + colInfoList.push({ + col, + idx, + useDecimals, + decimalPlaceLen, + }); + } + + for (let rowIdx = 1; rowIdx < td.dataTable.length; ++rowIdx) + { + let row = td.dataTable[rowIdx]; + for (const colInfo of colInfoList) + { + let val = row[colInfo.idx]; + + if (typeof val != "number") + { + continue; + } + + if (colInfo.useDecimals) + { + let precision = td.GetRowMetaData(rowIdx - 1)?.overlap?.style?.precisionByCol?.[colInfo.col]; + if (precision == undefined) + { + precision = colInfo.decimalPlaceLen; + } + + val = val.toFixed(precision); + } + + row[colInfo.idx] = utl.Commas(val); + } + } + } + + StyleOverlapData(td) + { + let colList = td.GetHeaderList(); + + for (let col of colList) + { + td.GenerateModifiedColumn([ + col + ], (row, idx) => { + let val = td.Get(row, col); + + if (val == null || val === "") + { + return [val]; + } + + let overlapStyle = td.GetRowMetaData(idx)?.overlap?.style; + let dimmed = overlapStyle?.dimmedCols?.has?.(col) ?? false; + let italic = overlapStyle?.italicCols?.has?.(col) ?? false; + + if (!dimmed && !italic) + { + return [val]; + } + + let classList = []; + if (dimmed) { classList.push("overlapDimmed"); } + if (italic) { classList.push("overlapItalic"); } + + return [`${val}`]; + }); + } + } + + Linkify(td) + { + let linkifyLatLngPair = (latCol, lngCol) => { + if (td.Idx(latCol) == undefined || td.Idx(lngCol) == undefined) + { + return; + } + + let rawPairByRowIdx = []; + td.ForEach((row, idx) => { + rawPairByRowIdx[idx] = { + latVal: td.Get(row, latCol), + lngVal: td.Get(row, lngCol), + }; + }); + + let makeLinkedVal = (row, rowIdx, colToRender) => { + let latVal = rawPairByRowIdx[rowIdx]?.latVal; + let lngVal = rawPairByRowIdx[rowIdx]?.lngVal; + + if (latVal == null || latVal === "" || lngVal == null || lngVal === "") + { + return [td.Get(row, colToRender)]; + } + + let latNum = Number(String(latVal).replaceAll(",", "")); + let lngNum = Number(String(lngVal).replaceAll(",", "")); + if (!Number.isFinite(latNum) || !Number.isFinite(lngNum)) + { + return [td.Get(row, colToRender)]; + } + + let gmUrl = WSPREncoded.MakeGoogleMapsLink(latNum, lngNum); + return [`${td.Get(row, colToRender)}`]; + }; + + td.GenerateModifiedColumn([latCol], (row, idx) => makeLinkedVal(row, idx, latCol)); + td.GenerateModifiedColumn([lngCol], (row, idx) => makeLinkedVal(row, idx, lngCol)); + }; + + if (td.Idx("Grid")) + { + // linkify grid + td.GenerateModifiedColumn([ + "Grid" + ], row => { + let grid = td.Get(row, "Grid"); + + let retVal = [grid]; + + if (grid) + { + let [lat, lng] = WSPREncoded.DecodeMaidenheadToDeg(grid); + + let gmUrl = WSPREncoded.MakeGoogleMapsLink(lat, lng); + let gridLink = `${grid}`; + + retVal = [gridLink]; + } + + return retVal; + }); + } + + if (td.Idx("RegGrid")) + { + // linkify grid4 + td.GenerateModifiedColumn([ + "RegGrid" + ], row => { + let grid4 = td.Get(row, "RegGrid"); + + let retVal = [grid4]; + + if (grid4) + { + let [lat, lng] = WSPREncoded.DecodeMaidenheadToDeg(grid4); + + let gmUrl = WSPREncoded.MakeGoogleMapsLink(lat, lng); + let grid4Link = `${grid4}`; + + retVal = [grid4Link]; + } + + return retVal; + }); + } + + if (td.Idx("BtGrid6")) + { + td.GenerateModifiedColumn([ + "BtGrid6" + ], row => { + let grid6 = td.Get(row, "BtGrid6"); + + let retVal = [grid6]; + + if (grid6) + { + let [lat, lng] = WSPREncoded.DecodeMaidenheadToDeg(grid6); + + let gmUrl = WSPREncoded.MakeGoogleMapsLink(lat, lng); + let grid6Link = `${grid6}`; + + retVal = [grid6Link]; + } + + return retVal; + }); + } + + [ + ["Lat", "Lng"], + ["RegLat", "RegLng"], + ["BtLat", "BtLng"], + ["EbtLat", "EbtLng"], + ["HiResLat", "HiResLng"], + ].forEach(([latCol, lngCol]) => linkifyLatLngPair(latCol, lngCol)); + } + + LinkifyUserOrVendorDefined(td, type) + { + for (let slot = 0; slot < 5; ++slot) + { + let colName = `slot${slot}.${type}.EncMsg`; + let msgDef = type == "ud" ? + this.msgDefinitionUserDefinedList[slot] : + this.msgDefinitionVendorDefinedList[slot]; + + if (td.Idx(colName)) + { + td.GenerateModifiedColumn([ + colName + ], row => { + let val = td.Get(row, colName); + + let retVal = [val]; + + if (val) + { + let link = ``; + link += `/pro/codec/`; + link += `?codec=${encodeURIComponent(msgDef)}`; + link += `&decode=${val}`; + link += `&encode=`; + + let a = `${val}`; + + retVal = [a]; + } + + return retVal; + }); + } + } + } + + PrioritizeColumnOrder(td) + { + WsprSearchUiDataTableColumnOrder.Apply(td); + } + + ModifyWebpageFormatting(td, table) + { + this.ModifyWebpageFormattingBasic(td, table); + this.ModifyWebpageFormattingExtendedUserAndVendorDefined(table); + } + + ModifyWebpageFormattingBasic(td, table) + { + let cd = new CSSDynamic(); + + // column header colors + for (let ccName of ["Pro"]) + { + cd.SetCssClassProperties(`${ccName}_hdr`, { + backgroundColor: "lemonchiffon", + }); + } + + for (let ccName of ["RegCall", "RegGrid", "RegPower", "RegLat", "RegLng"]) + { + cd.SetCssClassProperties(`${ccName}_hdr`, { + backgroundColor: "lightgreen", + }); + } + + for (let ccName of ["BtGpsValid", "BtGrid56", "BtGrid6", "BtLat", "BtLng", "BtKPH", "BtMPH"]) + { + cd.SetCssClassProperties(`${ccName}_hdr`, { + backgroundColor: "lightpink", + }); + } + + for (let ccName of ["BtTempC", "BtTempF", "BtVoltage", "BtAltM", "BtAltFt"]) + { + cd.SetCssClassProperties(`${ccName}_hdr`, { + backgroundColor: "lightpink", + }); + } + + // Heartbeat + for (let ccName of ["TxFreqHzIdx", "TxFreqMhz", "UptimeMinutes", "GpsLockType", "GpsTryLockSeconds", "GpsSatsInViewCount"]) + { + cd.SetCssClassProperties(`${ccName}_hdr`, { + backgroundColor: "lavenderblush", + }); + } + + for (let ccName of ["Lat", "Lng", "TempF", "TempC", "Voltage", "AltFt", "AltM", "KPH", "MPH", "AltChgFpm", "GpsMPH", "DistMi", "AltChgMpm", "GpsKPH", "DistKm", "SolAngle", "RxStationCount", "WinFreqDrift"]) + { + cd.SetCssClassProperties(`${ccName}_hdr`, { + backgroundColor: "khaki", + }); + } + + for (let ccName of ["EbtTempF", "EbtTempC", "EbtVoltage", "EbtGpsValid", "EbtLatitudeIdx", "EbtLongitudeIdx", "EbtLat", "EbtLng", "EbtAltFt", "EbtAltM"]) + { + cd.SetCssClassProperties(`${ccName}_hdr`, { + backgroundColor: "moccasin", + }); + } + + for (let ccName of ["HiResReference", "HiResLatitudeIdx", "HiResLongitudeIdx", "HiResLat", "HiResLng"]) + { + cd.SetCssClassProperties(`${ccName}_hdr`, { + backgroundColor: "lavender", + }); + } + + cd.SetCssClassProperties(`overlapDimmed`, { + opacity: "0.55", + color: "#666", + }); + + cd.SetCssClassProperties(`overlapItalic`, { + fontStyle: "italic", + }); + + + // give table a border + table.classList.add(`DataTable`); + + cd.SetCssClassProperties(`DataTable`, { + border: "1px solid black", + borderSpacing: 0, + // using this breaks the nice sticky header + // borderCollapse: "collapse", + borderCollapse: "separate", + }); + + // make sticky header + let trHeader = table.tHead.rows[0]; + trHeader.classList.add(`DataTableHeader`); + + cd.SetCssClassProperties(`DataTableHeader`, { + border: "1px solid black", + top: "0px", + position: "sticky", + background: "white", + }); + + // make data rows highlight when you mouse over them + cd.SetCssClassProperties(`DataTable tr:hover`, { + backgroundColor: "rgb(215, 237, 255)", + }); + + // look for every column header class name that applies to the column. + // ie column classes that end with _col. + let colClassList = []; + + const colGroupList = table.querySelectorAll('colgroup'); // should just be the one + + for (let colGroup of colGroupList) + { + for (let childNode of colGroup.childNodes) + { + for (let className of childNode.classList) + { + let suffix = className.slice(-4); + + if (suffix == "_col") + { + colClassList.push(className); + } + } + } + } + + // give minor styling to all cells in the table, by column property. + // this allows more nuanced control by other css properties to affect + // cells beyond this. + for (let colClass of colClassList) + { + cd.SetCssClassProperties(colClass, { + textAlign: "center", + padding: "2px", + }); + } + + + // do column groupings + let columnGroupLeftRightList = [ + ["Pro", "Pro" ], + ["DateTimeUtc", "DateTimeUtc" ], + ["DateTimeLocal", "DateTimeLocal"], + ["RegCall", "RegLng" ], + ["BtGpsValid", "BtMPH" ], + ["EbtGpsValid", "EbtAltM" ], + ["HiResReference","HiResLng" ], + ["Lat", "WinFreqDrift" ], + ["AltM", "DistKm" ], + ["AltFt", "DistMi" ], + ]; + + // do column groupings for dynamic columns + let GetSlotColumnBorderList = (startsWith) => { + let columnBorderList = [] + + // find headers related to this column set + let slotHeaderList = td.GetHeaderList().filter(str => str.startsWith(startsWith)); + + // style the encoded message so it has a border on each side + let colEncMsgRaw = `${startsWith}.EncMsg`; + let colEncMsg = utl.StrToCssClassName(colEncMsgRaw); + columnBorderList.push([colEncMsg, colEncMsg]); + + // remove the encoded message column to find remaining + slotHeaderList = slotHeaderList.filter(col => col != colEncMsgRaw); + + // style remaining columns so a vertical border leftmost and rightmost + if (slotHeaderList.length) + { + let colStartRaw = slotHeaderList[0]; + let colEndRaw = slotHeaderList[slotHeaderList.length - 1]; + + let colStart = utl.StrToCssClassName(colStartRaw); + let colEnd = utl.StrToCssClassName(colEndRaw); + + columnBorderList.push([colStart, colEnd]); + } + + return columnBorderList; + }; + + for (let slot = 0; slot < 5; ++slot) + { + columnGroupLeftRightList.push(...GetSlotColumnBorderList(`slot${slot}.ud`)); + columnGroupLeftRightList.push(...GetSlotColumnBorderList(`slot${slot}.vd`)); + } + + // apply style to groupings + for (let columnGroupLeftRight of columnGroupLeftRightList) + { + let [colLeft, colRight] = columnGroupLeftRight; + + cd.SetCssClassProperties(`${colLeft}_col`, { + borderLeft: "1px solid black", + borderCollapse: "collapse", + }); + + cd.SetCssClassProperties(`${colRight}_col`, { + borderRight: "1px solid black", + borderCollapse: "collapse", + }); + } + } + + ModifyWebpageFormattingExtendedUserAndVendorDefined(table) + { + let cd = new CSSDynamic(); + + for (let slot = 0; slot < 5; ++slot) + { + cd.SetCssClassProperties(utl.StrToCssClassName(`slot${slot}.ud.EncMsg_data`), { + textAlign: "left", + }); + cd.SetCssClassProperties(utl.StrToCssClassName(`slot${slot}.vd.EncMsg_data`), { + textAlign: "left", + }); + } + } + + StackColumnNameByDot(table) + { + let thList = Array.from(table.querySelectorAll('th')); + + for (let th of thList) + { + let partList = th.innerHTML.split('.'); + + if (partList.length >= 2) + { + let prefixList = partList; + let lastPart = prefixList.pop(); + + let prefixStr = prefixList.join("."); + + let stackedVal = `${prefixStr}
${lastPart}`; + th.innerHTML = stackedVal; + } + } + } + + ApplyHeaderDisplayAliases(table) + { + let aliasMap = new Map([ + ["HiResReference", "HiResRef"], + ["HiResLatitudeIdx", "HiResLatIdx"], + ["HiResLongitudeIdx", "HiResLngIdx"], + ["EbtLatitudeIdx", "EbtLatIdx"], + ["EbtLongitudeIdx", "EbtLngIdx"], + ["GpsTryLockSeconds", "GpsTryLockSecs"], + ]); + + let thList = Array.from(table.querySelectorAll("th")); + for (let th of thList) + { + let rawHeader = th.textContent?.trim?.(); + if (!aliasMap.has(rawHeader)) + { + continue; + } + + th.textContent = aliasMap.get(rawHeader); + } + } + + MakeUI() + { + let ui = document.createElement("div"); + let dtc = this.#MakeDataTableControls(); + let links = this.#MakeDownloadAndCopyLinks(); + + // assemble + ui.appendChild(dtc); + ui.appendChild(links); + + return ui; + } + + #UpdateDownloadAndCopyLinks() + { + let FilenamePrefix = () => { + return `${this.band}_${this.channelPadded}_${this.callsign}_${this.gte}_${this.lte}`; + }; + this.linkCsv.onclick = e => { + utl.DownloadCsv(utl.MakeFilename(`${FilenamePrefix()}.csv`), utl.TableToCsv(this.table)); + }; + let kmlFilenameWithAlt = `${FilenamePrefix()}_with_altitude.kml`; + this.linkKmlWithAltitude.onclick = e => { + utl.DownloadKml(utl.MakeFilename(kmlFilenameWithAlt), this.#ToKml(this.table, kmlFilenameWithAlt, true)); + }; + let kmlFilenameNoAlt = `${FilenamePrefix()}_no_altitude.kml`; + this.linkKmlNoAltitude.onclick = e => { + utl.DownloadKml(utl.MakeFilename(kmlFilenameNoAlt), this.#ToKml(this.table, kmlFilenameNoAlt)); + }; + this.linkCopyToClipboard.onclick = e => { + utl.CopyToClipboard(this.table); + }; + } + + #MakeDownloadAndCopyLinks() + { + let container = document.createElement('div'); + + this.linkCsv = document.createElement('span'); + this.linkCsv.innerHTML = "csv"; + + this.linkKmlWithAltitude = document.createElement('span'); + this.linkKmlWithAltitude.innerHTML = "kml w/ altitude"; + + this.linkKmlNoAltitude = document.createElement('span'); + this.linkKmlNoAltitude.innerHTML = "kml no altitude"; + + this.linkCopyToClipboard = document.createElement('span'); + this.linkCopyToClipboard.innerHTML = "(copy to clipboard)"; + + // style + let domList = [ + this.linkCsv, + this.linkKmlWithAltitude, + this.linkKmlNoAltitude, + this.linkCopyToClipboard, + ]; + + for (let dom of domList) + { + dom.style.cursor = "pointer"; + dom.style.color = "blue"; + dom.style.textDecoration = "underline"; + } + + // pack + // (download csv or kml w/ altitude or kml no altitude) or (copy to clipboard) + container.appendChild(document.createTextNode('(download ')); + container.appendChild(this.linkCsv); + container.appendChild(document.createTextNode(' or ')); + container.appendChild(this.linkKmlWithAltitude); + container.appendChild(document.createTextNode(' or ')); + container.appendChild(this.linkKmlNoAltitude); + container.appendChild(document.createTextNode(') or ')); + container.appendChild(this.linkCopyToClipboard); + + return container; + } + + #ToKml(table, filename, withAlt) + { + let retVal = ""; + + retVal += ` + + + ${filename} + Your Flight + `; + + if (this.td.Length() >= 1) + { + let coordsLast = null; + + let outputLineNum = 0; + + this.td.ForEach(row => { + let grid = this.td.Get(row, "Grid"); + + if (grid) + { + ++outputLineNum; + + // cells are already linkified, etc, so extract plain text + grid = grid.match(/>(.*) + ${dtLocal} + ${dtLocal} + #yellowLineGreenPoly + + 1 + 1 + ${altType} + +`; +retVal += ` ` + coordsLast; +retVal += ` ` + coords; +retVal += +` + + +`; + + } + + coordsLast = coords; + } + }); + } + + // add in north and south pole, and finish xml + retVal += ` + + North Pole + North Pole + + 0,90,0 + + + + South Pole + South Pole + + 0,-90,0 + + + +`; + + retVal += "\n"; + + return retVal; + } + + #MakeDataTableControls() + { + let NotifyVisibilityChanged = () => { + this.Emit("DATA_TABLE_VISIBILITY_CHANGED"); + }; + let resetToDefaultFnList = []; + + // Helper function + let FnClassHideShow = (colList, show) => { + let cd = new CSSDynamic(); + + let display = "none"; + if (show) + { + display = "table-cell"; + } + + for (let col of colList) + { + cd.SetCssClassProperties(utl.StrToCssClassName(`${col}_col`), { + display: display, + }); + } + }; + + this.fnClassHideShow = FnClassHideShow; + + let FnClassPreviewHover = (colList, show) => { + if (!this.table) + { + return; + } + + for (let col of colList) + { + let cssHdr = utl.StrToCssClassName(`${col}_hdr`); + let cssData = utl.StrToCssClassName(`${col}_data`); + + for (let dom of this.table.querySelectorAll(`.${cssData}`)) + { + dom.style.backgroundColor = show ? "rgb(215, 237, 255)" : ""; + dom.style.color = show ? "black" : ""; + } + + for (let dom of this.table.querySelectorAll(`.${cssHdr}`)) + { + dom.style.backgroundColor = show ? "rgb(215, 237, 255)" : ""; + dom.style.color = show ? "black" : ""; + } + } + }; + + let WireHoverPreview = (labelDom, colListOrGetter) => { + let getColList = () => { + if (typeof colListOrGetter == "function") + { + return colListOrGetter() ?? []; + } + + return colListOrGetter ?? []; + }; + + labelDom.addEventListener("mouseenter", () => { + labelDom.style.backgroundColor = "rgb(215, 237, 255)"; + FnClassPreviewHover(getColList(), true); + }); + labelDom.addEventListener("mouseleave", () => { + labelDom.style.backgroundColor = ""; + FnClassPreviewHover(getColList(), false); + }); + }; + + let MakeCheckboxControl = (name, specList) => { + let storageKey = `checkbox.${name}`; + let storedVal = localStorage.getItem(storageKey); + let storedMap = new Map(); + + if (storedVal != null) + { + try + { + let obj = JSON.parse(storedVal); + for (let [col, checked] of Object.entries(obj)) + { + storedMap.set(col, !!checked); + } + } + catch + { + } + } + + let update = () => { + let obj = {}; + for (const spec of specList) + { + FnClassHideShow([spec.col], spec.input.checked); + obj[spec.col] = !!spec.input.checked; + } + + localStorage.setItem(storageKey, JSON.stringify(obj)); + NotifyVisibilityChanged(); + }; + + let MakeLabelForSpec = (spec) => { + let label = document.createElement("label"); + label.style.whiteSpace = "nowrap"; + + label.appendChild(spec.input); + label.appendChild(document.createTextNode(` ${spec.label}`)); + WireHoverPreview(label, [spec.col]); + + return label; + }; + + let MakeContainer = (filterFn) => { + let container = document.createElement("span"); + let specListFiltered = specList.filter(filterFn); + + for (let idx = 0; idx < specListFiltered.length; ++idx) + { + let spec = specListFiltered[idx]; + let label = MakeLabelForSpec(spec); + container.appendChild(label); + + if (idx != specListFiltered.length - 1) + { + container.appendChild(document.createTextNode(" ")); + } + } + + return container; + }; + + for (let idx = 0; idx < specList.length; ++idx) + { + let spec = specList[idx]; + let input = document.createElement("input"); + input.type = "checkbox"; + input.checked = storedMap.has(spec.col) ? storedMap.get(spec.col) : !!spec.checked; + input.addEventListener("change", update); + spec.input = input; + } + + update(); + resetToDefaultFnList.push(() => { + for (const spec of specList) + { + spec.input.checked = !!spec.checked; + } + update(); + }); + + return { + commonUi: MakeContainer(spec => !!spec.checked), + lessCommonUi: MakeContainer(spec => !spec.checked), + makeCustomUiFromLabels: (labelGroupList) => { + let outer = document.createElement("div"); + outer.style.display = "inline-grid"; + outer.style.gridTemplateColumns = `repeat(${labelGroupList.length}, auto)`; + outer.style.columnGap = "16px"; + outer.style.alignItems = "start"; + outer.style.justifyContent = "start"; + outer.style.width = "max-content"; + + for (let labelList of labelGroupList) + { + let col = document.createElement("div"); + col.style.display = "flex"; + col.style.flexDirection = "column"; + col.style.gap = "2px"; + + for (let labelText of labelList) + { + let spec = specList.find(spec => spec.label == labelText); + if (spec) + { + col.appendChild(MakeLabelForSpec(spec)); + } + } + + outer.appendChild(col); + } + + return outer; + }, + }; + }; + + let MakeSingleCheckbox = (storageKey, labelText, defaultChecked, onChange) => { + let container = document.createElement("span"); + let label = document.createElement("label"); + label.style.whiteSpace = "nowrap"; + + let input = document.createElement("input"); + input.type = "checkbox"; + input.checked = WsprSearchUiDataTableVisibility.GetStoredToggle(storageKey, defaultChecked); + input.addEventListener("change", () => { + localStorage.setItem(storageKey, input.checked ? "yes" : "no"); + onChange(input.checked); + NotifyVisibilityChanged(); + }); + + label.appendChild(input); + label.appendChild(document.createTextNode(` ${labelText}`)); + if (typeof onChange.colListGetter == "function") + { + WireHoverPreview(label, onChange.colListGetter); + } + container.appendChild(label); + + onChange(input.checked); + resetToDefaultFnList.push(() => { + input.checked = defaultChecked; + localStorage.setItem(storageKey, input.checked ? "yes" : "no"); + onChange(input.checked); + NotifyVisibilityChanged(); + }); + + return container; + }; + + let GetUserVendorDefinedColList = (type, raw) => { + let colList = []; + for (let slot = 0; slot < 5; ++slot) + { + let prefix = `slot${slot}.${type}.`; + for (let col of this.tdSource?.GetHeaderList?.() ?? []) + { + if (!col.startsWith(prefix)) + { + continue; + } + + let isRaw = col == `${prefix}EncMsg`; + if ((raw && isRaw) || (!raw && !isRaw)) + { + colList.push(col); + } + } + } + + return colList; + }; + + let udDecodedOnChange = checked => { + FnClassHideShow(GetUserVendorDefinedColList("ud", false), checked); + }; + udDecodedOnChange.colListGetter = () => GetUserVendorDefinedColList("ud", false); + let udDecodedControl = MakeSingleCheckbox("udDecodedVisible", "Decoded", true, udDecodedOnChange); + + let udRawOnChange = checked => { + FnClassHideShow(GetUserVendorDefinedColList("ud", true), checked); + }; + udRawOnChange.colListGetter = () => GetUserVendorDefinedColList("ud", true); + let udRawControl = MakeSingleCheckbox("udRawVisible", "Raw", true, udRawOnChange); + + let vdDecodedOnChange = checked => { + FnClassHideShow(GetUserVendorDefinedColList("vd", false), checked); + }; + vdDecodedOnChange.colListGetter = () => GetUserVendorDefinedColList("vd", false); + let vdDecodedControl = MakeSingleCheckbox("vdDecodedVisible", "Decoded", true, vdDecodedOnChange); + + let vdRawOnChange = checked => { + FnClassHideShow(GetUserVendorDefinedColList("vd", true), checked); + }; + vdRawOnChange.colListGetter = () => GetUserVendorDefinedColList("vd", true); + let vdRawControl = MakeSingleCheckbox("vdRawVisible", "Raw", true, vdRawOnChange); + + let MakeInlineControlGroup = (uiList) => { + let container = document.createElement("span"); + let first = true; + for (let ui of uiList) + { + if (!ui) + { + continue; + } + + if (!first) + { + container.appendChild(document.createTextNode(" ")); + } + + container.appendChild(ui); + first = false; + } + + return container; + }; + + let dateTimeSpecList = WsprSearchUiDataTableVisibility.GetDateTimeSpecList(); + let dateTimeControl = MakeCheckboxControl("dateTimeVisible", [ + { label: "DateTimeUtc", ...dateTimeSpecList[0] }, + { label: "DateTimeLocal", ...dateTimeSpecList[1] }, + ]); + + let basicTelemetrySpecList = WsprSearchUiDataTableVisibility.GetCheckboxSpecList("basicTelemetryVisible"); + let expandedBasicTelemetrySpecList = WsprSearchUiDataTableVisibility.GetCheckboxSpecList("expandedBasicTelemetryVisible"); + let highResLocationSpecList = WsprSearchUiDataTableVisibility.GetCheckboxSpecList("highResLocationVisible"); + let resolvedSpecList = WsprSearchUiDataTableVisibility.GetCheckboxSpecList("resolvedVisible"); + let regularType1SpecList = WsprSearchUiDataTableVisibility.GetCheckboxSpecList("regularType1Visible"); + let heartbeatSpecList = WsprSearchUiDataTableVisibility.GetCheckboxSpecList("heartbeatVisible"); + + // Handle Basic Telemetry column hide/show + let basicTelemetryControl = MakeCheckboxControl("basicTelemetryVisible", [ + { label: "GpsValid", ...basicTelemetrySpecList[0] }, + { label: "Grid56", ...basicTelemetrySpecList[1] }, + { label: "Grid6", ...basicTelemetrySpecList[2] }, + { label: "Lat", ...basicTelemetrySpecList[3] }, + { label: "Lng", ...basicTelemetrySpecList[4] }, + { label: "TempC", ...basicTelemetrySpecList[5] }, + { label: "TempF", ...basicTelemetrySpecList[6] }, + { label: "Voltage", ...basicTelemetrySpecList[7] }, + { label: "AltM", ...basicTelemetrySpecList[8] }, + { label: "AltFt", ...basicTelemetrySpecList[9] }, + { label: "KPH", ...basicTelemetrySpecList[10] }, + { label: "MPH", ...basicTelemetrySpecList[11] }, + ]); + let basicTelemetryLessCommonUi = basicTelemetryControl.makeCustomUiFromLabels([ + ["Grid6", "Grid56", "Lat", "Lng", "Voltage"], + ["TempF", "AltFt", "MPH"], + ["TempC", "AltM", "KPH"], + ]); + + let expandedBasicTelemetryControl = MakeCheckboxControl("expandedBasicTelemetryVisible", [ + { label: "GpsValid", ...expandedBasicTelemetrySpecList[0] }, + { label: "Voltage", ...expandedBasicTelemetrySpecList[1] }, + { label: "Lat", ...expandedBasicTelemetrySpecList[2] }, + { label: "Lng", ...expandedBasicTelemetrySpecList[3] }, + { label: "LatIdx", ...expandedBasicTelemetrySpecList[4] }, + { label: "LngIdx", ...expandedBasicTelemetrySpecList[5] }, + { label: "TempF", ...expandedBasicTelemetrySpecList[6] }, + { label: "AltFt", ...expandedBasicTelemetrySpecList[7] }, + { label: "TempC", ...expandedBasicTelemetrySpecList[8] }, + { label: "AltM", ...expandedBasicTelemetrySpecList[9] }, + ]); + let expandedBasicTelemetryLessCommonUi = expandedBasicTelemetryControl.makeCustomUiFromLabels([ + ["Lat", "Lng", "LatIdx", "LngIdx"], + ["TempF", "AltFt"], + ["TempC", "AltM"], + ]); + + let highResLocationControl = MakeCheckboxControl("highResLocationVisible", [ + { label: "Lat", ...highResLocationSpecList[0] }, + { label: "Lng", ...highResLocationSpecList[1] }, + { label: "Ref", ...highResLocationSpecList[2] }, + { label: "LatIdx", ...highResLocationSpecList[3] }, + { label: "LngIdx", ...highResLocationSpecList[4] }, + ]); + + let resolvedControl = MakeCheckboxControl("resolvedVisible", [ + { label: "Lat", ...resolvedSpecList[0] }, + { label: "Lng", ...resolvedSpecList[1] }, + { label: "TempF", ...resolvedSpecList[2] }, + { label: "TempC", ...resolvedSpecList[3] }, + { label: "Voltage", ...resolvedSpecList[4] }, + { label: "AltFt", ...resolvedSpecList[5] }, + { label: "AltM", ...resolvedSpecList[6] }, + { label: "KPH", ...resolvedSpecList[7] }, + { label: "MPH", ...resolvedSpecList[8] }, + { label: "AltChgFpm", ...resolvedSpecList[9] }, + { label: "GpsMPH", ...resolvedSpecList[10] }, + { label: "DistMi", ...resolvedSpecList[11] }, + { label: "AltChgMpm", ...resolvedSpecList[12] }, + { label: "GpsKPH", ...resolvedSpecList[13] }, + { label: "DistKm", ...resolvedSpecList[14] }, + { label: "SolAngle", ...resolvedSpecList[15] }, + { label: "RxStationCount", ...resolvedSpecList[16] }, + { label: "WinFreqDrift", ...resolvedSpecList[17] }, + ]); + let resolvedCommonUi = resolvedControl.makeCustomUiFromLabels([ + ["Lat", "Lng", "Voltage"], + ["TempF", "AltFt", "AltChgFpm"], + ["MPH", "GpsMPH", "DistMi"], + ["TempC", "AltM", "AltChgMpm"], + ["KPH", "GpsKPH", "DistKm"], + ["SolAngle", "RxStationCount", "WinFreqDrift"], + ]); + + let regularType1Control = MakeCheckboxControl("regularType1Visible", [ + { label: "Call", ...regularType1SpecList[0] }, + { label: "Grid", ...regularType1SpecList[1] }, + { label: "Power", ...regularType1SpecList[2] }, + { label: "Lat", ...regularType1SpecList[3] }, + { label: "Lng", ...regularType1SpecList[4] }, + ]); + + // Handle Heartbeat column hide/show + let heartbeatControl = MakeCheckboxControl("heartbeatVisible", [ + { label: "UptimeMinutes", ...heartbeatSpecList[0] }, + { label: "GpsLockType", ...heartbeatSpecList[1] }, + { label: "GpsTryLockSecs", ...heartbeatSpecList[2] }, + { label: "GpsSatsInViewCount", ...heartbeatSpecList[3] }, + { label: "TxFreqHzIdx", ...heartbeatSpecList[4] }, + { label: "TxFreqMhz", ...heartbeatSpecList[5] }, + ]); + + // Handle Pro column hide/show + let proOnChange = checked => { + this.showProColumn = checked; + FnClassHideShow(["Pro"], this.showProColumn); + NotifyVisibilityChanged(); + }; + proOnChange.colListGetter = () => ["Pro"]; + let proControl = MakeSingleCheckbox("proVisible", "Pro", true, proOnChange); + + // text to show + let summary = document.createElement('summary'); + summary.innerHTML = "Data Table Controls (click to open/close)"; + + let applyDefaultColumnVisibility = () => { + for (let fn of resetToDefaultFnList) + { + fn(); + } + }; + + let btnResetDefaults = document.createElement("button"); + btnResetDefaults.type = "button"; + btnResetDefaults.textContent = "Default Column Visibility"; + btnResetDefaults.style.marginBottom = "8px"; + btnResetDefaults.addEventListener("click", () => { + applyDefaultColumnVisibility(); + }); + + let table = document.createElement("table"); + let thead = document.createElement("thead"); + let trHead = document.createElement("tr"); + let thView = document.createElement("th"); + let thCommon = document.createElement("th"); + let thLessCommon = document.createElement("th"); + thView.textContent = "Source"; + thCommon.textContent = "Show Common"; + thLessCommon.textContent = "Show Less Common"; + thView.style.textAlign = "left"; + thCommon.style.textAlign = "left"; + thLessCommon.style.textAlign = "left"; + thView.style.paddingRight = "10px"; + thCommon.style.paddingRight = "10px"; + trHead.appendChild(thView); + trHead.appendChild(thCommon); + trHead.appendChild(thLessCommon); + thead.appendChild(trHead); + + let tbody = document.createElement("tbody"); + let rowList = [ + ["DateTime", dateTimeControl.commonUi, dateTimeControl.lessCommonUi], + ["RegularType1", regularType1Control.commonUi, regularType1Control.lessCommonUi], + [`BasicTelemetry`, basicTelemetryControl.commonUi, basicTelemetryLessCommonUi], + ["Merged / Synth", resolvedCommonUi, null], + [`Heartbeat`, heartbeatControl.commonUi, heartbeatControl.lessCommonUi], + [`ExpandedBasicTelemetry`, expandedBasicTelemetryControl.commonUi, expandedBasicTelemetryLessCommonUi], + [`HighResLocation`, highResLocationControl.commonUi, highResLocationControl.lessCommonUi], + ["UserDefined", MakeInlineControlGroup([udRawControl, udDecodedControl]), null], + ["VendorDefined", MakeInlineControlGroup([vdRawControl, vdDecodedControl]), null], + ["Pro", proControl, null], + ]; + + for (const [labelHtml, commonUi, lessCommonUi] of rowList) + { + let tr = document.createElement("tr"); + + let tdLabel = document.createElement("td"); + tdLabel.innerHTML = labelHtml; + tdLabel.style.paddingRight = "10px"; + + let tdCommon = document.createElement("td"); + tdCommon.style.paddingRight = "10px"; + if (commonUi) + { + tdCommon.appendChild(commonUi); + } + + let tdLessCommon = document.createElement("td"); + if (lessCommonUi) + { + tdLessCommon.appendChild(lessCommonUi); + } + + tr.appendChild(tdLabel); + tr.appendChild(tdCommon); + tr.appendChild(tdLessCommon); + tbody.appendChild(tr); + } + + table.appendChild(thead); + table.appendChild(tbody); + + // make details container + let details = document.createElement('details'); + details.open = this.#GetDataTableControlsOpenState(); + details.addEventListener("toggle", () => { + this.#SetDataTableControlsOpenState(details.open); + }); + + this.#RunUiUpgradeIfNeeded({ + details, + applyDefaultColumnVisibility, + }); + + // assemble + details.appendChild(summary); + details.appendChild(btnResetDefaults); + details.appendChild(table); + details.appendChild(document.createElement('br')); + + return details; + } + + #GetDataTableControlsOpenState() + { + let cookieName = `${WsprSearchUiDataTableController.DETAILS_COOKIE_NAME}=`; + let cookiePartList = document.cookie.split(";"); + for (let cookiePart of cookiePartList) + { + cookiePart = cookiePart.trim(); + if (cookiePart.startsWith(cookieName)) + { + return cookiePart.substring(cookieName.length) == "1"; + } + } + + return true; + } + + #SetDataTableControlsOpenState(open) + { + let maxAgeSeconds = 60 * 60 * 24 * 365; + let val = open ? "1" : "0"; + document.cookie = `${WsprSearchUiDataTableController.DETAILS_COOKIE_NAME}=${val}; path=/; max-age=${maxAgeSeconds}; samesite=lax`; + } + + #GetStoredUiUpgradeVersion() + { + return Number(localStorage.getItem(WsprSearchUiDataTableController.UI_UPGRADE_VERSION_KEY) ?? 0); + } + + #SetStoredUiUpgradeVersion(version) + { + localStorage.setItem(WsprSearchUiDataTableController.UI_UPGRADE_VERSION_KEY, String(version)); + } + + #NeedsUiUpgrade() + { + return this.#GetStoredUiUpgradeVersion() < WsprSearchUiDataTableController.UI_UPGRADE_VERSION; + } + + #RunUiUpgradeStep(version, { details, applyDefaultColumnVisibility }) + { + switch (version) + { + case 1: + details.open = true; + this.#SetDataTableControlsOpenState(true); + applyDefaultColumnVisibility(); + break; + + case 2: + this.Emit("SHOW_MAP_CONTROL_HELP"); + break; + } + } + + #RunUiUpgradeIfNeeded(args) + { + if (!this.#NeedsUiUpgrade()) + { + return; + } + + let versionStored = this.#GetStoredUiUpgradeVersion(); + for (let version = versionStored + 1; version <= WsprSearchUiDataTableController.UI_UPGRADE_VERSION; ++version) + { + this.#RunUiUpgradeStep(version, args); + this.#SetStoredUiUpgradeVersion(version); + } + } + + #RenderDataTable() + { + if (this.tdSource == null) + { + return; + } + + // full re-render (controls + table) + this.cfg.container.innerHTML = ""; + this.proRowControllerList = []; + + // copy data so it can be modified without affecting other holders + this.td = this.tdSource.Clone(); + + // jazz up data content + this.ModifyTableContentsForDisplay(this.td); + + // build the shell immediately, then parse/append body rows incrementally + this.table = this.#MakeIncrementalTableShell(this.td.GetDataTable()); + + // jazz up webpage presentation + this.ModifyWebpageFormatting(this.td, this.table); + + // save width with dotted column headers (ie slotx.field) + this.StackColumnNameByDot(this.table); + this.ApplyHeaderDisplayAliases(this.table); + this.#ApplyUserVendorDefinedVisibility(); + + // replace with new + this.cfg.container.appendChild(this.ui); + this.cfg.container.appendChild(this.table); + + this.#AppendTableRowsIncrementally(this.renderTableToken); + } + + #MakeIncrementalTableShell(dataTable) + { + if (!dataTable?.length) + { + return utl.MakeTable([[]]); + } + + return utl.MakeTable([dataTable[0]]); + } + + #AppendTableRowsIncrementally(token) + { + let dataTable = this.td.GetDataTable(); + let tbody = this.table?.tBodies?.[0]; + if (!tbody || dataTable.length <= 1) + { + this.#WireProRowDialogs(); + this.#TryHandlePendingJumpToData(); + return; + } + + let rowHeader = dataTable[0]; + let classAttrByIdx = rowHeader.map(colVal => { + let colClass = ""; + let dataClass = ""; + + try + { + colClass = utl.StrToCssClassName(`${colVal}_col`); + dataClass = utl.StrToCssClassName(`${colVal}_data`); + } + catch (e) + { + } + + let classList = [colClass, dataClass].filter(str => str != ""); + return classList.length ? ` class="${classList.join(" ")}"` : ""; + }); + + let EscapeHtml = (val) => { + return String(val) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); + }; + + let rowIdx = 1; + let CHUNK_ROW_COUNT = 200; + + let appendChunk = () => { + if (token != this.renderTableToken || tbody.isConnected == false) + { + return; + } + + let html = ""; + let trListAdded = []; + let rowIdxStart = rowIdx; + let count = 0; + + while (rowIdx < dataTable.length && count < CHUNK_ROW_COUNT) + { + let row = dataTable[rowIdx]; + + let hasElement = false; + for (let idx = 0; idx < row.length; ++idx) + { + if (row[idx] instanceof Element) + { + hasElement = true; + break; + } + } + + if (hasElement) + { + if (html != "") + { + tbody.insertAdjacentHTML("beforeend", html); + let trListNew = Array.from(tbody.rows).slice(-(rowIdx - rowIdxStart)); + trListAdded.push(...trListNew); + html = ""; + rowIdxStart = rowIdx; + } + + let tr = document.createElement("tr"); + for (let idx = 0; idx < row.length; ++idx) + { + let td = document.createElement("td"); + + let colClass = utl.StrToCssClassName(`${rowHeader[idx]}_col`); + let dataClass = utl.StrToCssClassName(`${rowHeader[idx]}_data`); + if (colClass != "") + { + td.classList.add(colClass); + } + if (dataClass != "") + { + td.classList.add(dataClass); + } + + let val = row[idx]; + if (val instanceof Element) + { + td.appendChild(val); + } + else if (typeof val == "string" && val.indexOf("<") != -1) + { + td.innerHTML = val; + } + else + { + td.textContent = val ?? ""; + } + + tr.appendChild(td); + } + + tbody.appendChild(tr); + trListAdded.push(tr); + ++rowIdx; + ++count; + rowIdxStart = rowIdx; + continue; + } + + html += ""; + for (let idx = 0; idx < row.length; ++idx) + { + let colVal = row[idx]; + let cellHtml = ""; + if (typeof colVal == "string" && colVal.indexOf("<") != -1) + { + cellHtml = colVal; + } + else + { + cellHtml = EscapeHtml(colVal ?? ""); + } + + html += `${cellHtml}`; + } + html += ""; + + ++rowIdx; + ++count; + } + + if (html != "") + { + let rowCountHtml = rowIdx - rowIdxStart; + tbody.insertAdjacentHTML("beforeend", html); + let trListNew = Array.from(tbody.rows).slice(-rowCountHtml); + trListAdded.push(...trListNew); + } + + this.#WireProRowDialogsInRows(trListAdded, rowIdx - trListAdded.length - 1); + this.#TryHandlePendingJumpToData(); + + if (rowIdx < dataTable.length) + { + if (window.requestIdleCallback) + { + window.requestIdleCallback(() => appendChunk(), { timeout: 100 }); + } + else + { + window.setTimeout(() => { + window.requestAnimationFrame(() => appendChunk()); + }, 0); + } + } + }; + + appendChunk(); + } + + #ApplyUserVendorDefinedVisibility() + { + if (!this.fnClassHideShow || !this.tdSource) + { + return; + } + + let applyPrefixVisibility = (type, showDecoded, showRaw) => { + let decodedColList = []; + let rawColList = []; + + for (let slot = 0; slot < 5; ++slot) + { + let prefix = `slot${slot}.${type}.`; + for (let col of this.tdSource.GetHeaderList()) + { + if (!col.startsWith(prefix)) + { + continue; + } + + if (col == `${prefix}EncMsg`) + { + rawColList.push(col); + } + else + { + decodedColList.push(col); + } + } + } + + this.fnClassHideShow(decodedColList, showDecoded); + this.fnClassHideShow(rawColList, showRaw); + }; + + applyPrefixVisibility( + "ud", + WsprSearchUiDataTableVisibility.GetStoredToggle("udDecodedVisible", true), + WsprSearchUiDataTableVisibility.GetStoredToggle("udRawVisible", false), + ); + + applyPrefixVisibility( + "vd", + WsprSearchUiDataTableVisibility.GetStoredToggle("vdDecodedVisible", true), + WsprSearchUiDataTableVisibility.GetStoredToggle("vdRawVisible", false), + ); + } + + #WireProRowDialogs() + { + let tbody = this.table.tBodies[0]; + if (!tbody) + { + return; + } + + let trList = Array.from(tbody.rows); + this.#WireProRowDialogsInRows(trList, 0); + } + + #WireProRowDialogsInRows(trList, rowIdxOffset = 0) + { + for (let rowIdx = 0; rowIdx < trList.length; ++rowIdx) + { + let tr = trList[rowIdx]; + tr.dataset.rowIdx = `${rowIdxOffset + rowIdx}`; + let emoji = tr.querySelector(".proInsightEmoji"); + if (!emoji) + { + continue; + } + + let controller = null; + + emoji.addEventListener("click", () => { + if (controller == null) + { + controller = new WsprSearchUiDataTableRowProController({ + data: { + dt: this.td, + rowIdx: rowIdxOffset + rowIdx, + wsprSearch: this.wsprSearch, + band: this.band, + channel: this.channel, + }, + }); + + this.proRowControllerList.push(controller); + document.body.appendChild(controller.GetUI()); + } + + controller.Show(); + }); + } + } + + #TryHandlePendingJumpToData() + { + if (!this.pendingJumpToDataTs || !this.table || !this.td) + { + return; + } + + let targetRowIdx = -1; + this.td.ForEach((row, idx) => { + if (targetRowIdx == -1 && this.td.Get(row, "DateTimeLocal") == this.pendingJumpToDataTs) + { + targetRowIdx = idx; + } + }); + + if (targetRowIdx == -1) + { + return; + } + + let tr = this.table.querySelector(`tbody tr[data-row-idx="${targetRowIdx}"]`); + if (!tr) + { + return; + } + + let headerHeight = this.table.tHead?.offsetHeight ?? 0; + let top = window.scrollY + tr.getBoundingClientRect().top - headerHeight - 2; + window.scrollTo({ + top: Math.max(0, top), + behavior: "auto", + }); + + this.pendingJumpToDataTs = null; + } +} diff --git a/js/WsprSearchUiDataTableRowProController.js b/js/WsprSearchUiDataTableRowProController.js new file mode 100644 index 0000000..7793f9c --- /dev/null +++ b/js/WsprSearchUiDataTableRowProController.js @@ -0,0 +1,790 @@ +/* +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 { CollapsableTitleBox, DialogBox } from './DomWidgets.js'; +import { WSPR } from '/js/WSPR.js'; + +export class WsprSearchUiDataTableRowProController +{ + constructor(cfg) + { + this.cfg = cfg || {}; + this.data = this.cfg.data || {}; + + this.dialog = new DialogBox(); + this.dialog.SetTitleBar("Pro Row Insight"); + this.hasShown = false; + this.colWidthList = ["20%", "12%", "6%", "37%", "25%"]; + + let content = this.dialog.GetContentContainer(); + content.style.width = "1110px"; + content.style.minWidth = "1110px"; + content.style.minHeight = "520px"; + content.style.maxHeight = "calc(100vh - 120px)"; + + this.body = document.createElement("div"); + this.body.style.padding = "8px"; + this.body.style.display = "flex"; + this.body.style.flexDirection = "column"; + this.body.style.gap = "8px"; + content.appendChild(this.body); + + this.#RefreshBody(); + } + + GetUI() + { + return this.dialog.GetUI(); + } + + Show() + { + this.#RefreshBody(); + if (!this.hasShown) + { + let ui = this.dialog.GetUI(); + ui.style.left = "80px"; + ui.style.top = "80px"; + this.hasShown = true; + } + this.dialog.Show(); + } + + #RefreshBody() + { + this.body.innerHTML = ""; + this.rxRecord__nodeSet = new Map(); + this.rxRecordHighlightNodeSet = new Set(); + + let dt = this.data.dt; + let rowIdx = this.data.rowIdx; + let dtUtc = ""; + let dtLocal = ""; + if (dt && rowIdx != undefined) + { + dtUtc = dt.Get(rowIdx, "DateTimeUtc") || ""; + dtLocal = dt.Get(rowIdx, "DateTimeLocal") || ""; + } + this.dialog.SetTitleBar(`Pro Row Insight - DateTimeUtc: ${dtUtc} | DateTimeLocal: ${dtLocal}`); + + let slotMsgListList = this.#GetSlotMsgListList(); + for (let slot = 0; slot < 5; ++slot) + { + this.body.appendChild(this.#MakeSlotSection(slot, slotMsgListList[slot] || [])); + } + } + + #GetSlotMsgListList() + { + let slotMsgListList = [[], [], [], [], []]; + + let dt = this.data.dt; + let rowIdx = this.data.rowIdx; + let wsprSearch = this.data.wsprSearch; + let rowMeta = dt && rowIdx != undefined ? dt.GetRowMetaData(rowIdx) : null; + let time = rowMeta?.time; + + // Preferred source: full per-window slot message lists from WsprSearch. + let windowData = wsprSearch?.time__windowData?.get?.(time); + if (windowData?.slotDataList) + { + for (let slot = 0; slot < 5; ++slot) + { + slotMsgListList[slot] = windowData.slotDataList[slot]?.msgList || []; + } + return slotMsgListList; + } + + // Fallback source: selected single-candidate-per-slot snapshot. + let slotMsgList = rowMeta?.slotMsgList || []; + for (let slot = 0; slot < 5; ++slot) + { + let msg = slotMsgList[slot]; + if (msg) + { + slotMsgListList[slot] = [msg]; + } + } + + return slotMsgListList; + } + + #MakeSlotSection(slotIdx, msgList) + { + let section = new CollapsableTitleBox(); + section.SetTitle(`Slot ${slotIdx} (click to open/collapse)`); + section.SetMinWidth("0px"); + section.Show(); + let sectionUi = section.GetUI(); + let sectionBody = section.GetContentContainer(); + + let table = document.createElement("table"); + table.style.borderCollapse = "collapse"; + table.style.backgroundColor = "white"; + table.style.width = "100%"; + table.style.tableLayout = "fixed"; + this.#AppendColGroup(table); + + let thead = document.createElement("thead"); + let htr = document.createElement("tr"); + this.#AppendCell(htr, "Type", true); + this.#AppendCell(htr, "Message", true); + this.#AppendCell(htr, "Count", true); + this.#AppendCell(htr, "Status", true); + this.#AppendCell(htr, "Details", true); + thead.appendChild(htr); + table.appendChild(thead); + + let tbody = document.createElement("tbody"); + let msgGroupList = this.#GetMsgGroupList(msgList); + if (msgGroupList.length == 0) + { + let tr = document.createElement("tr"); + this.#AppendCell(tr, "-", false); + this.#AppendCell(tr, "-", false); + this.#AppendCell(tr, "0", false); + this.#AppendCell(tr, "-", false); + this.#SetRowBackground(tr, "#ffecec"); + let tdDetails = this.#MakeDetailsCell(); + this.#SetDetailsPlaceholder(tdDetails, "No messages in this slot.", false); + tr.appendChild(tdDetails); + tbody.appendChild(tr); + } + else + { + let candidateRowCount = 0; + for (const g of msgGroupList) + { + candidateRowCount += g.isActive ? 1 : 0; + } + + let tdDetails = this.#MakeDetailsCell(); + tdDetails.rowSpan = msgGroupList.length; + this.#SetDetailsPlaceholder(tdDetails, "Click row to show/hide RX details.", true); + let activeRowIdx = -1; + let activeTr = null; + + for (let idx = 0; idx < msgGroupList.length; ++idx) + { + let g = msgGroupList[idx]; + let tr = document.createElement("tr"); + tr.style.cursor = "pointer"; + let suppressClickForSelection = false; + + this.#AppendCell(tr, g.msgTypeDisplay, false); + this.#AppendCell(tr, g.msgDisplay, false); + this.#AppendCell(tr, g.rxCount.toString(), false); + this.#AppendCell(tr, g.rejectReason, false); + let isSingleCandidateRow = candidateRowCount == 1 && g.isActive; + this.#SetRowBackground(tr, isSingleCandidateRow ? "#ecffec" : "#ffecec"); + + if (idx == 0) + { + tr.appendChild(tdDetails); + } + + tr.addEventListener("mousedown", (e) => { + if (e.detail > 1) + { + e.preventDefault(); + window.getSelection?.()?.removeAllRanges?.(); + suppressClickForSelection = false; + return; + } + + suppressClickForSelection = this.#SelectionIntersectsNode(tr); + }); + + tr.addEventListener("dblclick", (e) => { + e.preventDefault(); + }); + + tr.addEventListener("click", () => { + if (suppressClickForSelection || this.#SelectionIntersectsNode(tr)) + { + suppressClickForSelection = false; + return; + } + + suppressClickForSelection = false; + + if (activeTr) + { + this.#SetRowActiveStyle(activeTr, tdDetails, false); + } + + if (activeRowIdx == idx) + { + activeRowIdx = -1; + activeTr = null; + this.#SetDetailsPlaceholder(tdDetails, "Click row to show/hide RX details.", true); + return; + } + + activeRowIdx = idx; + activeTr = tr; + tdDetails.textContent = ""; + tdDetails.appendChild(this.#MakeRxDetailsUi(g.rxRecordList, slotIdx, g)); + this.#SetRowActiveStyle(activeTr, tdDetails, true); + }); + + tbody.appendChild(tr); + } + } + + table.appendChild(tbody); + sectionBody.appendChild(table); + + return sectionUi; + } + + #AppendCell(tr, text, isHeader) + { + let td = document.createElement(isHeader ? "th" : "td"); + td.textContent = text; + td.style.border = "1px solid #999"; + td.style.padding = "3px 6px"; + td.style.verticalAlign = "top"; + td.style.whiteSpace = "normal"; + td.style.overflowWrap = "anywhere"; + if (isHeader) + { + td.style.backgroundColor = "#ddd"; + td.style.textAlign = "left"; + } + tr.appendChild(td); + } + + #AppendColGroup(table) + { + // Keep a consistent width profile across every slot table. + let cg = document.createElement("colgroup"); + for (const w of this.colWidthList) + { + let col = document.createElement("col"); + col.style.width = w; + cg.appendChild(col); + } + table.appendChild(cg); + } + + #MakeDetailsCell() + { + let td = document.createElement("td"); + td.style.border = "1px solid #999"; + td.style.padding = "3px 6px"; + td.style.verticalAlign = "top"; + td.style.minWidth = "0"; + td.style.whiteSpace = "pre-wrap"; + td.style.fontFamily = "monospace"; + td.style.userSelect = "text"; + td.style.cursor = "text"; + + // Keep details text selectable/copyable without toggling row state. + td.addEventListener("click", (e) => { + e.stopPropagation(); + }); + return td; + } + + #SelectionIntersectsNode(node) + { + let selection = window.getSelection?.(); + if (!selection || selection.rangeCount == 0 || selection.isCollapsed) + { + return false; + } + + let range = selection.getRangeAt(0); + let commonAncestor = range.commonAncestorContainer; + return node.contains(commonAncestor); + } + + #SetDetailsPlaceholder(td, text, useDim) + { + td.textContent = ""; + let span = document.createElement("span"); + span.textContent = text; + if (useDim) + { + span.style.color = "#666"; + } + td.appendChild(span); + } + + #SetRowBackground(tr, color) + { + for (const cell of tr.cells) + { + if (cell.tagName == "TD") + { + cell.style.backgroundColor = color; + } + } + } + + #SetRowActiveStyle(tr, tdDetails, active) + { + // Reset row/detail cell active effect only (do not mutate border geometry). + for (const cell of tr.cells) + { + if (cell.tagName == "TD") + { + cell.style.boxShadow = "none"; + } + } + tdDetails.style.boxShadow = "none"; + + if (!active) + { + return; + } + + // Active row: emphasize inward (inset) so layout does not expand outward. + for (let i = 0; i < tr.cells.length; ++i) + { + let cell = tr.cells[i]; + if (cell.tagName != "TD") + { + continue; + } + + let insetTopBottom = "inset 0 2px 0 #444, inset 0 -2px 0 #444"; + let insetLeft = i == 0 ? ", inset 2px 0 0 #444" : ""; + cell.style.boxShadow = insetTopBottom + insetLeft; + } + + // Keep details visually tied to active row with inward emphasis. + tdDetails.style.boxShadow = "inset 0 2px 0 #444, inset 0 -2px 0 #444, inset 2px 0 0 #444, inset -2px 0 0 #444"; + } + + #GetMsgGroupList(msgList) + { + let keyToGroup = new Map(); + + for (const msg of msgList) + { + if (!msg) + { + continue; + } + + let key = this.#GetMsgKey(msg); + if (!keyToGroup.has(key)) + { + keyToGroup.set(key, { + msgTypeDisplay: this.#GetMsgTypeDisplay(msg), + msgDisplay: this.#GetMsgDisplay(msg), + rejectReason: this.#GetRejectReason(msg), + isCandidate: false, + isConfirmed: false, + isActive: false, + msgList: [], + rxRecordList: [], + }); + } + + let g = keyToGroup.get(key); + g.isCandidate = g.isCandidate || (msg.IsCandidate && msg.IsCandidate()); + g.isConfirmed = g.isConfirmed || (msg.IsConfirmed && msg.IsConfirmed()); + g.isActive = g.isActive || (msg.IsNotRejected && msg.IsNotRejected()); + g.msgList.push(msg); + if (Array.isArray(msg.rxRecordList)) + { + g.rxRecordList.push(...msg.rxRecordList); + } + } + + let out = Array.from(keyToGroup.values()); + for (const g of out) + { + g.rxCount = g.rxRecordList.length; + } + + out.sort((a, b) => { + if (b.rxCount != a.rxCount) + { + return b.rxCount - a.rxCount; + } + return a.msgDisplay.localeCompare(b.msgDisplay); + }); + + return out; + } + + #GetMsgKey(msg) + { + let f = msg.fields || {}; + let decodeType = msg.decodeDetails?.type || ""; + return JSON.stringify([ + msg.type || "", + decodeType, + f.callsign || "", + f.grid4 || "", + f.powerDbm || "", + ]); + } + + #GetMsgDisplay(msg) + { + let f = msg.fields || {}; + return `${f.callsign || ""} ${f.grid4 || ""} ${f.powerDbm || ""}`.trim(); + } + + #GetMsgTypeDisplay(msg) + { + let type = msg.type || ""; + if (type == "telemetry") + { + let decodeType = msg.decodeDetails?.type || "?"; + if (decodeType == "extended") + { + let prettyType = msg.decodeDetails?.extended?.prettyType || ""; + if (prettyType != "") + { + return `telemetry/${prettyType}`; + } + } + return `telemetry/${decodeType}`; + } + + if (type == "regular") + { + return "regular"; + } + + return type || "-"; + } + + #GetRejectReason(msg) + { + let reason = ""; + + if (msg.IsConfirmed && msg.IsConfirmed()) + { + reason = "Confirmed"; + } + else if (msg.IsCandidate && msg.IsCandidate()) + { + reason = "Candidate"; + } + else + { + let audit = msg.candidateFilterAuditList?.[0]; + if (!audit) + { + reason = "Rejected"; + } + else if (audit.note) + { + reason = `${audit.type || "Rejected"}: ${audit.note}`; + } + else + { + reason = audit.type || "Rejected"; + } + } + + if (this.#IsFingerprintReferenceMsg(msg)) + { + reason += reason != "" ? " (frequency reference)" : "Frequency reference"; + } + + return reason; + } + + #IsFingerprintReferenceMsg(msg) + { + let referenceMsg = this.data.wsprSearch + ?.time__windowData + ?.get?.(this.data.dt?.GetRowMetaData(this.data.rowIdx)?.time) + ?.fingerprintingData + ?.referenceAudit + ?.referenceMsg; + + return referenceMsg === msg; + } + + #MakeRxDetailsUi(rxRecordList, slotIdx, msgGroup) + { + let expectedFreqHz = this.#GetExpectedFreqHz(); + let rowList = []; + for (const rx of rxRecordList) + { + let freq = ""; + let freqSort = Number.NaN; + let offset = null; + if (rx?.frequency != undefined && rx?.frequency !== "") + { + let n = Number(rx.frequency); + if (Number.isFinite(n)) + { + freqSort = n; + freq = n.toLocaleString("en-US"); + if (expectedFreqHz != null) + { + offset = Math.round(n - expectedFreqHz); + } + } + else + { + freq = `${rx.frequency}`; + } + } + + rowList.push({ + rxRecord: rx, + station: rx?.rxCallsign ? rx.rxCallsign : "", + freq: freq, + freqSort: freqSort, + offset: offset, + }); + } + + rowList.sort((a, b) => { + let aOffsetValid = a.offset != null; + let bOffsetValid = b.offset != null; + if (aOffsetValid && bOffsetValid && a.offset != b.offset) + { + return b.offset - a.offset; + } + if (aOffsetValid != bOffsetValid) + { + return aOffsetValid ? -1 : 1; + } + + let c1 = a.station.localeCompare(b.station); + if (c1 != 0) { return c1; } + + let aFreqValid = Number.isFinite(a.freqSort); + let bFreqValid = Number.isFinite(b.freqSort); + if (aFreqValid && bFreqValid && a.freqSort != b.freqSort) + { + return a.freqSort - b.freqSort; + } + + return a.freq.localeCompare(b.freq); + }); + + let hdr = { + station: "RX Station", + freq: "RX Freq", + offset: "+/-LaneHz", + }; + + let wStation = hdr.station.length; + let wFreq = hdr.freq.length; + let wOffsetValue = 3; + for (const r of rowList) + { + wStation = Math.max(wStation, r.station.length); + wFreq = Math.max(wFreq, r.freq.length); + if (r.offset != null) + { + wOffsetValue = Math.max(wOffsetValue, Math.abs(r.offset).toString().length); + } + } + let wOffset = Math.max(hdr.offset.length, 1 + wOffsetValue); + + let wrapper = document.createElement("div"); + wrapper.style.whiteSpace = "pre"; + wrapper.style.userSelect = "text"; + wrapper.style.fontFamily = "monospace"; + wrapper.style.fontSize = "12px"; + + let makeLine = (station, freq, offset) => + `${station.padEnd(wStation)} ${freq.padStart(wFreq)} ${offset.padStart(wOffset)}`; + + let headerLine = this.#MakeDetailsTextRow(makeLine(hdr.station, hdr.freq, hdr.offset), false); + wrapper.appendChild(headerLine); + wrapper.appendChild(this.#MakeDetailsTextRow(makeLine("-".repeat(wStation), "-".repeat(wFreq), "-".repeat(wOffset)), false)); + + if (rowList.length == 0) + { + wrapper.appendChild(this.#MakeDetailsTextRow("(no receiving stations)", false)); + } + else + { + for (const r of rowList) + { + let line = makeLine(r.station, r.freq, this.#FormatLaneOffset(r.offset, wOffsetValue)); + let rowNode = this.#MakeDetailsTextRow(line, true); + + this.#RegisterRxRecordNode(r.rxRecord, rowNode); + rowNode.addEventListener("mouseenter", () => { + this.#ApplyFingerprintHover(slotIdx, msgGroup, r.rxRecord); + }); + rowNode.addEventListener("mouseleave", () => { + this.#ClearFingerprintHover(); + }); + + wrapper.appendChild(rowNode); + } + } + + return wrapper; + } + + #GetExpectedFreqHz() + { + let band = this.data.band || ""; + let channel = this.data.channel; + if (band == "" || channel == "" || channel == undefined) + { + return null; + } + + let channelDetails = WSPR.GetChannelDetails(band, channel); + if (!channelDetails || !Number.isFinite(channelDetails.freq)) + { + return null; + } + + return channelDetails.freq; + } + + #FormatLaneOffset(offset, width) + { + if (offset == null) + { + return ""; + } + + let sign = " "; + if (offset > 0) + { + sign = "+"; + } + else if (offset < 0) + { + sign = "-"; + } + + return `${sign}${Math.abs(offset).toString().padStart(width)}`; + } + + #MakeDetailsTextRow(text, interactive) + { + let row = document.createElement("div"); + row.textContent = text; + row.style.whiteSpace = "pre"; + row.style.cursor = interactive ? "default" : "text"; + return row; + } + + #RegisterRxRecordNode(rxRecord, node) + { + if (!this.rxRecord__nodeSet.has(rxRecord)) + { + this.rxRecord__nodeSet.set(rxRecord, new Set()); + } + + this.rxRecord__nodeSet.get(rxRecord).add(node); + } + + #ApplyFingerprintHover(slotIdx, msgGroup, rxRecord) + { + this.#ClearFingerprintHover(); + + let rxRecordSet = this.#GetFingerprintHoverRxRecordSet(slotIdx, msgGroup, rxRecord); + if (rxRecordSet.size == 0) + { + return; + } + + for (const rxRecordHighlighted of rxRecordSet) + { + let nodeSet = this.rxRecord__nodeSet.get(rxRecordHighlighted); + if (!nodeSet) + { + continue; + } + + for (const node of nodeSet) + { + node.style.backgroundColor = "#eefbe7"; + this.rxRecordHighlightNodeSet.add(node); + } + } + } + + #ClearFingerprintHover() + { + for (const node of this.rxRecordHighlightNodeSet) + { + node.style.backgroundColor = ""; + } + this.rxRecordHighlightNodeSet.clear(); + } + + #GetFingerprintHoverRxRecordSet(slotIdx, msgGroup, rxRecord) + { + let rxRecordSet = new Set(); + let referenceAudit = this.data.wsprSearch + ?.time__windowData + ?.get?.(this.data.dt?.GetRowMetaData(this.data.rowIdx)?.time) + ?.fingerprintingData + ?.referenceAudit; + + if (!referenceAudit) + { + return rxRecordSet; + } + + if (slotIdx == referenceAudit.referenceSlot) + { + for (const slotAudit of referenceAudit.slotAuditList) + { + if (!slotAudit || slotAudit.slot == referenceAudit.referenceSlot) + { + continue; + } + + this.#AddFingerprintMatchesForRecord(rxRecordSet, slotAudit.msgAuditList, rxRecord, "rxRecordListA"); + } + + return rxRecordSet; + } + + let slotAudit = referenceAudit.slotAuditList[slotIdx]; + if (!slotAudit) + { + return rxRecordSet; + } + + let msgSet = new Set(msgGroup.msgList || []); + let msgAuditList = (slotAudit.msgAuditList || []).filter(msgAudit => msgSet.has(msgAudit.msg)); + this.#AddFingerprintMatchesForRecord(rxRecordSet, msgAuditList, rxRecord, "rxRecordListB"); + + return rxRecordSet; + } + + #AddFingerprintMatchesForRecord(rxRecordSet, msgAuditList, rxRecord, primaryListName) + { + for (const msgAudit of msgAuditList || []) + { + for (const rxCallMatch of msgAudit.rxCallMatchList || []) + { + let primaryList = rxCallMatch[primaryListName] || []; + if (!primaryList.includes(rxRecord)) + { + continue; + } + + for (const rxRecordA of rxCallMatch.rxRecordListA || []) + { + rxRecordSet.add(rxRecordA); + } + for (const rxRecordB of rxCallMatch.rxRecordListB || []) + { + rxRecordSet.add(rxRecordB); + } + } + } + } +} diff --git a/js/WsprSearchUiDataTableVisibility.js b/js/WsprSearchUiDataTableVisibility.js new file mode 100644 index 0000000..c26c623 --- /dev/null +++ b/js/WsprSearchUiDataTableVisibility.js @@ -0,0 +1,239 @@ +/* +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.) +*/ + +export class WsprSearchUiDataTableVisibility +{ + static GetStoredToggle(storageKey, defaultValue) + { + let storedVal = localStorage.getItem(storageKey); + if (storedVal == null) + { + return defaultValue; + } + + return storedVal == "yes"; + } + + static GetDateTimeSpecList() + { + return [ + { col: "DateTimeUtc", checked: true }, + { col: "DateTimeLocal", checked: true }, + ]; + } + + static GetCheckboxSpecListMap() + { + return new Map([ + ["basicTelemetryVisible", [ + { col: "BtGpsValid", checked: true }, + { col: "BtGrid56", checked: false }, + { col: "BtGrid6", checked: false }, + { col: "BtLat", checked: false }, + { col: "BtLng", checked: false }, + { col: "BtTempC", checked: false }, + { col: "BtTempF", checked: false }, + { col: "BtVoltage", checked: false }, + { col: "BtAltM", checked: false }, + { col: "BtAltFt", checked: false }, + { col: "BtKPH", checked: false }, + { col: "BtMPH", checked: false }, + ]], + ["expandedBasicTelemetryVisible", [ + { col: "EbtGpsValid", checked: true }, + { col: "EbtVoltage", checked: true }, + { col: "EbtLat", checked: false }, + { col: "EbtLng", checked: false }, + { col: "EbtLatitudeIdx", checked: false }, + { col: "EbtLongitudeIdx", checked: false }, + { col: "EbtTempF", checked: false }, + { col: "EbtAltFt", checked: false }, + { col: "EbtTempC", checked: false }, + { col: "EbtAltM", checked: false }, + ]], + ["highResLocationVisible", [ + { col: "HiResLat", checked: true }, + { col: "HiResLng", checked: true }, + { col: "HiResReference", checked: false }, + { col: "HiResLatitudeIdx", checked: false }, + { col: "HiResLongitudeIdx", checked: false }, + ]], + ["resolvedVisible", [ + { col: "Lat", checked: true }, + { col: "Lng", checked: true }, + { col: "TempF", checked: true }, + { col: "TempC", checked: false }, + { col: "Voltage", checked: true }, + { col: "AltFt", checked: true }, + { col: "AltM", checked: false }, + { col: "KPH", checked: false }, + { col: "MPH", checked: true }, + { col: "AltChgFpm", checked: true }, + { col: "GpsMPH", checked: true }, + { col: "DistMi", checked: true }, + { col: "AltChgMpm", checked: false }, + { col: "GpsKPH", checked: false }, + { col: "DistKm", checked: false }, + { col: "SolAngle", checked: true }, + { col: "RxStationCount", checked: true }, + { col: "WinFreqDrift", checked: true }, + ]], + ["regularType1Visible", [ + { col: "RegCall", checked: true }, + { col: "RegGrid", checked: false }, + { col: "RegPower", checked: true }, + { col: "RegLat", checked: false }, + { col: "RegLng", checked: false }, + ]], + ["heartbeatVisible", [ + { col: "UptimeMinutes", checked: true }, + { col: "GpsLockType", checked: true }, + { col: "GpsTryLockSeconds", checked: true }, + { col: "GpsSatsInViewCount", checked: true }, + { col: "TxFreqHzIdx", checked: false }, + { col: "TxFreqMhz", checked: false }, + ]], + ]); + } + + static GetCheckboxSpecList(storageKey) + { + return [... (this.GetCheckboxSpecListMap().get(storageKey) ?? [])]; + } + + static GetVisibleColumnsForStorageKey(storageKey) + { + let specList = []; + + if (storageKey == "dateTimeVisible") + { + specList = this.GetDateTimeSpecList(); + } + else + { + specList = this.GetCheckboxSpecList(storageKey); + } + + let visibleColSet = new Set(); + let visibilityMap = this.#GetStoredCheckboxMap(`checkbox.${storageKey}`, specList); + + for (let spec of specList) + { + if (visibilityMap.get(spec.col)) + { + visibleColSet.add(spec.col); + } + } + + if (storageKey == "dateTimeVisible" && localStorage.getItem("checkbox.dateTimeVisible") == null) + { + let dateTimeVisible = localStorage.getItem("dateTimeVisible") ?? "both"; + if (dateTimeVisible == "utc") + { + visibleColSet.delete("DateTimeLocal"); + } + else if (dateTimeVisible == "local") + { + visibleColSet.delete("DateTimeUtc"); + } + } + + return visibleColSet; + } + + static GetVisibleColumnSet(allColList) + { + let visibleColSet = new Set(allColList); + + let hideFromSpec = (storageKey, defaultSpecList) => { + let visibilityMap = this.#GetStoredCheckboxMap(storageKey, defaultSpecList); + for (let spec of defaultSpecList) + { + if (!visibilityMap.get(spec.col)) + { + visibleColSet.delete(spec.col); + } + } + }; + + for (let [storageKey, specList] of this.GetCheckboxSpecListMap()) + { + hideFromSpec(`checkbox.${storageKey}`, specList); + } + + hideFromSpec(`checkbox.dateTimeVisible`, this.GetDateTimeSpecList()); + + // Backward compatibility with the prior radio-button DateTime setting. + // Only apply this if the new checkbox state has never been stored. + if (localStorage.getItem("checkbox.dateTimeVisible") == null) + { + let dateTimeVisible = localStorage.getItem("dateTimeVisible") ?? "both"; + if (dateTimeVisible == "utc") + { + visibleColSet.delete("DateTimeLocal"); + } + else if (dateTimeVisible == "local") + { + visibleColSet.delete("DateTimeUtc"); + } + } + + let udDecodedVisible = this.GetStoredToggle("udDecodedVisible", true); + let udRawVisible = this.GetStoredToggle("udRawVisible", false); + let vdDecodedVisible = this.GetStoredToggle("vdDecodedVisible", true); + let vdRawVisible = this.GetStoredToggle("vdRawVisible", false); + + for (let col of allColList) + { + if (col.startsWith("slot") && col.includes(".ud.")) + { + let isRaw = col.endsWith(".EncMsg"); + if ((isRaw && !udRawVisible) || (!isRaw && !udDecodedVisible)) + { + visibleColSet.delete(col); + } + } + + if (col.startsWith("slot") && col.includes(".vd.")) + { + let isRaw = col.endsWith(".EncMsg"); + if ((isRaw && !vdRawVisible) || (!isRaw && !vdDecodedVisible)) + { + visibleColSet.delete(col); + } + } + } + + return visibleColSet; + } + + static #GetStoredCheckboxMap(storageKey, defaultSpecList) + { + let storedMap = new Map(defaultSpecList.map(spec => [spec.col, !!spec.checked])); + let storedVal = localStorage.getItem(storageKey); + + if (storedVal == null) + { + return storedMap; + } + + try + { + let obj = JSON.parse(storedVal); + for (let [col, checked] of Object.entries(obj)) + { + storedMap.set(col, !!checked); + } + } + catch + { + } + + return storedMap; + } +} diff --git a/js/WsprSearchUiFlightStatsController.js b/js/WsprSearchUiFlightStatsController.js new file mode 100644 index 0000000..87316e9 --- /dev/null +++ b/js/WsprSearchUiFlightStatsController.js @@ -0,0 +1,181 @@ +/* +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 { TabularData } from '../../../../js/TabularData.js'; + + + +export class WsprSearchUiFlightStatsController +extends Base +{ + constructor(cfg) + { + super(); + + this.cfg = cfg; + + this.ok = this.cfg.container; + + if (this.ok) + { + this.ui = this.#MakeUI(); + this.cfg.container.appendChild(this.ui); + } + } + + SetDebug(tf) + { + super.SetDebug(tf); + + this.t.SetCcGlobal(tf); + } + + OnEvent(evt) + { + if (this.ok) + { + switch (evt.type) { + case "DATA_TABLE_RAW_READY": this.#OnDataTableRawReady(evt); break; + } + } + } + + #OnDataTableRawReady(evt) + { + this.t.Reset(); + this.t.Event(`WsprSearchUiFlightStatsController::OnDataTableRawReady Start`); + + // clear existing child nodes + this.cfg.container.innerHTML = ""; + + // get handle to data + let td = evt.tabularDataReadOnly; + + // calculate distance stats + let distKm = 0; + let distMi = 0; + + td.ForEach((row) => { + distKm += td.Get(row, "DistKm"); + distMi += td.Get(row, "DistMi"); + }); + + // calculate spot stats + let spotCount = td.GetDataTable().length - 1; + + // calculate duration stats + let durationStr = ""; + if (td.Length() > 1) + { + let dtFirst = td.Get(td.Length() - 1, "DateTimeLocal"); + let dtLast = td.Get(0, "DateTimeLocal"); + + let msFirst = utl.ParseTimeToMs(dtFirst); + let msLast = utl.ParseTimeToMs(dtLast); + + let msDiff = msLast - msFirst; + durationStr = utl.MsToDurationStrDaysHoursMinutes(msDiff); + } + + // calculate eastward laps around world using resolved location + let lapCount = this.#CalculateEastwardLapCount(td); + + // create summary + let status = + ` +Flight duration: ${durationStr} +
+
+Laps around world: ${utl.Commas(lapCount)} +
+
+Distance Traveled Km: ${utl.Commas(Math.round(distKm))} +
+Distance Traveled Mi: ${utl.Commas(Math.round(distMi))} +
+
+Spots: ${utl.Commas(spotCount)} + `; + + // update UI + this.ui.innerHTML = status; + + // replace with new + this.cfg.container.appendChild(this.ui); + + this.t.Event(`WsprSearchUiFlightStatsController::OnDataTableRawReady End`); + } + + #MakeUI() + { + this.ui = document.createElement('div'); + + return this.ui; + } + + #CalculateEastwardLapCount(td) + { + let lonList = []; + + // Table is newest-first, so iterate oldest -> newest. + for (let idx = td.Length() - 1; idx >= 0; --idx) + { + let lon = td.Get(idx, "Lng"); + if (lon == undefined || lon == null || lon === "") + { + continue; + } + + lon = Number(lon); + if (Number.isFinite(lon)) + { + lonList.push(lon); + } + } + + if (lonList.length < 2) + { + return 0; + } + + // Unwrap longitude so east/west movement is continuous across +/-180. + let unwrappedLonList = [lonList[0]]; + for (let i = 1; i < lonList.length; ++i) + { + let prevRaw = lonList[i - 1]; + let curRaw = lonList[i]; + + let delta = curRaw - prevRaw; + if (delta > 180) { delta -= 360; } + if (delta < -180) { delta += 360; } + + let nextUnwrapped = unwrappedLonList[unwrappedLonList.length - 1] + delta; + unwrappedLonList.push(nextUnwrapped); + } + + let startLon = unwrappedLonList[0]; + let lapCount = 0; + + // Count only eastward full wraps past start + 360n. + for (let i = 1; i < unwrappedLonList.length; ++i) + { + let prevRel = unwrappedLonList[i - 1] - startLon; + let curRel = unwrappedLonList[i] - startLon; + + while (prevRel < (lapCount + 1) * 360 && curRel >= (lapCount + 1) * 360) + { + ++lapCount; + } + } + + return lapCount; + } +} diff --git a/js/WsprSearchUiInputController.js b/js/WsprSearchUiInputController.js new file mode 100644 index 0000000..b2f837a --- /dev/null +++ b/js/WsprSearchUiInputController.js @@ -0,0 +1,1376 @@ +/* +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 { + CollapsableTitleBox, + DialogBox +} from './DomWidgets.js'; +import { MsgDefinitionInputUiController } from './MsgDefinitionInputUiController.js'; +import { WSPR } from '/js/WSPR.js'; +import { TabularData } from '../../../../js/TabularData.js'; + + + +// Class takes care to get new search results for a given WsprSearch. +// +// Without Channels: +// - Just search aligned with every 2 minutes +// +// With Channels: +// - A given band and channel will indicate the start minute of a given +// 10-minute window. +// - The class wants to wait for all data in a 10-min window to become available +// before searching again. +// +// In both channel and non-channel: +// - It takes time for data to make its way through the wspr databases, often over +// a minute since transmission. +// - So offset the search time by 1 minute or more to "wait" for the data +// - Avoid thundering herd by offsetting the exact search start time by some random amount. +// +// To qualify to auto-refresh, the current time must be within the window of time specified +// by the search parameters. +// - Now must be greater or equal to the start time. +// - Now must be less than or equal to the end time +// - If the end time does not exist, it is considered infinite, and so now is less than it +// +class WsprRefreshTimer +{ + constructor() + { + this.fn = () => {}; + + this.timerId = null; + this.msTarget = 0; + + this.band = ""; + this.channel = "0"; + this.gte = ""; + this.lte = ""; + } + + SetCallbackOnTimeout(fn) + { + this.fn = fn; + } + + SetNewTimerSearchParameters(band, channel, gte, lte) + { + this.band = band; + this.channel = channel; + this.gte = gte; + this.lte = lte; + + this.#ScheduleNextTimeout(); + } + + Cancel() + { + clearTimeout(this.timerId); + this.timerId = null; + this.msTarget = 0; + } + + // can return null or a 0+ value + // null returned when there is no timeout set + GetTimeToNextTimeoutMs() + { + let ms = null; + + if (this.msTarget != 0) + { + let msNow = utl.Now(); + + if (msNow <= this.msTarget) + { + ms = this.msTarget - msNow; + } + } + + return ms; + } + + #OnTimeout() + { + this.fn(); + + this.#ScheduleNextTimeout(); + } + + #ScheduleNextTimeout() + { + this.Cancel(); + + let dbg = false; + + let msSince = utl.MsUntilDate(this.gte); + if (dbg) console.log(`msSince: ${msSince}`) + if (msSince > 0) { return; } + + let msUntil = utl.MsUntilDate(this.lte); + if (dbg) console.log(`msUntil: ${msUntil}`) + if (msUntil < 0) { return; } + + let cd = WSPR.GetChannelDetails(this.band, this.channel); + + + // Take current time + let msNow = utl.Now(); + let dtNow = utl.MakeDateTimeFromMs(msNow); // `${YYYY}-${MM}-${DD} ${hh}:${mm}:${ss}` + let minNow = dtNow.substr(15, 1); // single digit + + if (dbg) console.log(`minNow: ${minNow} from ${dtNow}`) + + + // figure the minute when prior transmission should have completed, we build from there + let windowStartMin = cd.min; + + // override for channel-less operation + if (this.channel == "") + { + let evenMinNow = Math.floor(minNow / 2) * 2; + + windowStartMin = evenMinNow; + } + + if (dbg) console.log(`windowStartMin: ${windowStartMin}`); + + + // how far into the future is the next window? + // calculate when the next time the window minute occurs in relation to now + let msIntoTheFuture = 0; + if (minNow == windowStartMin) + { + msIntoTheFuture = 0; + } + else if (minNow < windowStartMin) + { + msIntoTheFuture = (windowStartMin - minNow) * 60 * 1000; + } + else // minNow > targetMin + { + if (this.channel == "") + { + msIntoTheFuture = (2 - (minNow - windowStartMin)) * 60 * 1000; + } + else + { + msIntoTheFuture = (10 - (minNow - windowStartMin)) * 60 * 1000; + } + } + + if (dbg) console.log(`windowStart is ${msIntoTheFuture / 60000} min ${utl.Commas(msIntoTheFuture)} ms in the future (whole minutes)`) + + + + // what time will it be at that start minute? + + // first, create the time of day it will be + let msThen = msNow + msIntoTheFuture; + let dtThen = utl.MakeDateTimeFromMs(msThen); + if (dbg) console.log(`time then: ${dtThen}`) + + // strip the seconds off and replace to make an even minute + let dtThenMinutePrecise = dtThen.substring(0, dtThen.length - 2) + "00"; + if (dbg) console.log(`time then (min precise): ${dtThenMinutePrecise}`) + + // look at the ms time at the minute-price time + let msThenMinutePrecise = utl.ParseTimeToMs(dtThenMinutePrecise); + + + + // add buffer + let msTarget = msThenMinutePrecise; + msTarget += 1 * 60 * 1000; // add a minute buffer + msTarget += 7 * 1000; // add a bit more because it's not always on time + msTarget += Math.floor(Math.random() * 5000); // jitter to avoid thundering herd + + if (dbg) console.log(`buffer added ${utl.Commas(msTarget - msThenMinutePrecise)}`) + if (dbg) console.log(`target now: ${utl.MakeDateTimeFromMs(msTarget)}`) + + + + // distance into the future from now + let msDiff = msTarget - msNow; + + if (dbg) console.log(`msDiff: ${utl.Commas(msDiff)}`); + + + // channel to minute + // 581 = 0 + // 582 = 2 + // 583 = 4 + // 584 = 6 + // 585 = 8 + + + // schedule + this.timerId = setTimeout(() => { this.#OnTimeout(); }, msDiff); + this.msTarget = msTarget; + }; +} + + + +export class WsprSearchUiInputController +extends Base +{ + constructor(cfg) + { + super(); + + this.cfg = cfg; + + this.ok = this.cfg.container; + + if (this.ok) + { + this.mdiUdList = []; + this.mdiVdList = []; + this.showInitialLoadProgressOnNextSearch = false; + this.initialLoadProgressDialogShown = false; + + this.ui = this.#MakeUI(); + this.cfg.container.appendChild(this.ui); + this.startupGuidanceDialog = this.#MakeStartupGuidanceDialog(); + this.startupGuidanceDialog.GetUI().style.top = "100px"; + this.startupGuidanceDialog.GetUI().style.left = "100px"; + document.body.appendChild(this.startupGuidanceDialog.GetUI()); + this.initialLoadProgressModal = this.#MakeInitialLoadProgressModal(); + document.body.appendChild(this.initialLoadProgressModal); + this.#WireHeaderHelpLink(); + + this.refreshTimer = new WsprRefreshTimer(); + this.refreshTimer.SetCallbackOnTimeout(() => { + console.log(`timed out`); + + // cyclical, but who cares + this.#Search(); + }); + + // A user initiates this, so causing url serialization and + // a history entry makes sense + this.buttonInput.addEventListener('click', () => { + let ok = this.#ValidateInputs(); + + if (ok) + { + this.Emit("REQ_URL_GET"); + + this.#Search(); + } + else + { + this.#OnBadSearchAttempt(); + } + }); + + this.timerId = null; + this.td = null; + } + } + + GetBand() { return this.bandSelect.value; } + SetBand(val) { this.bandSelect.value = val; } + + GetChannel() { return this.channelInput.value; } + SetChannel(val) { this.channelInput.value = val; } + + GetCallsign() { return this.callsignInput.value.toUpperCase(); } + SetCallsign(val) { this.callsignInput.value = val } + + GetGte() { return this.gteInput.value; } + SetGte(val) { this.gteInput.value = val; } + + GetLte() { return this.#ConvertLte(this.lteInput.value); } + GetLteRaw() { return this.lteInput.value; } + SetLte(val) { this.lteInput.value = val; } + #ConvertLte(lte) + { + // let the end time (date) be inclusive + // so if you have 2023-04-28 as the end date, everything for the entire + // day should be considered. + // since the querying system wants a cutoff date (lte datetime), we + // just shift the date of today forward by an entire day, changing it from + // a cutoff of today at morning midnight to tomorrow at morning midnight. + // throw in an extra hour for daylight savings time scenarios + + let retVal = lte; + if (lte != "") + { + let ms = utl.ParseTimeToMs(lte); + ms += (25 * 60 * 60 * 1000); + + retVal = utl.MakeDateFromMs(ms); + } + + return retVal; + } + + OnEvent(evt) + { + switch (evt.type) { + case "ON_URL_SET": this.#OnUrlSet(evt); break; + case "ON_URL_GET": this.#OnUrlGet(evt); break; + case "SEARCH_PROGRESS": this.#OnSearchProgress(evt); break; + case "SEARCH_COMPLETE": this.#OnSearchComplete(); break; + case "DATA_TABLE_RAW_READY": this.#OnDataTableRawReady(evt); break; + } + } + + #OnUrlSet(evt) + { + this.SetBand(WSPR.GetDefaultBandIfNotValid(evt.Get("band", "20m"))); + this.SetChannel(evt.Get("channel", "")); + this.SetCallsign(evt.Get("callsign", "")); + this.SetGte(evt.Get("dtGte", "")); + this.SetLte(evt.Get("dtLte", "")); + + for (let slot = 0; slot < 5; ++slot) + { + // set what could be a blank msg + this.mdiUdList[slot].SetMsgDefinition(this.#GetUrlMsgDefinition(evt, slot, "user")); + + // trigger logic elsewhere to update display of slot header + this.mdiUdList[slot].GetOnApplyCallback()(false); + } + + for (let slot = 0; slot < 5; ++slot) + { + // set what could be a blank msg + this.mdiVdList[slot].SetMsgDefinition(this.#GetUrlMsgDefinition(evt, slot, "vendor")); + + // trigger logic elsewhere to update display of slot header + this.mdiVdList[slot].GetOnApplyCallback()(false); + } + + this.showInitialLoadProgressOnNextSearch = true; + let ok = this.#ValidateInputsAndMaybeSearch(); + if (!ok) + { + this.showInitialLoadProgressOnNextSearch = false; + this.Emit("SHOW_EMPTY_MAP"); + window.setTimeout(() => { + this.#ShowStartupGuidanceDialog(); + }, 250); + } + } + + #OnUrlGet(evt) + { + evt.Set("band", this.GetBand()); + evt.Set("channel", this.GetChannel()); + evt.Set("callsign", this.GetCallsign()); + evt.Set("dtGte", this.GetGte()); + evt.Set("dtLte", this.GetLteRaw()); + + for (let slot = 0; slot < 5; ++slot) + { + evt.Set(`slot${slot + 1}MsgDefUserDefined`, this.mdiUdList[slot].GetMsgDefinition()); + } + + for (let slot = 0; slot < 5; ++slot) + { + evt.Set(`slot${slot + 1}MsgDefVendorDefined`, this.mdiVdList[slot].GetMsgDefinition()); + } + } + + #ValidateInputs() + { + let ok = true; + + if (this.GetCallsign() == "") + { + ok = false; + this.callsignInput.style.backgroundColor = "pink"; + } + else + { + this.callsignInput.style.backgroundColor = "white"; + this.callsignInput.style.backgroundColor = ""; + } + + if (this.GetGte() == "") + { + let msNow = utl.Now(); + let msThen = msNow - (30 * 24 * 60 * 60 * 1000); + + this.SetGte(utl.MakeDateFromMs(msThen)); + } + + if (this.GetGte() != "" && this.GetLteRaw() != "") + { + const d1 = Date.parse(this.GetGte()); + const d2 = Date.parse(this.GetLteRaw()); + + if (d2 < d1) + { + ok = false; + + this.gteInput.style.backgroundColor = "pink"; + this.lteInput.style.backgroundColor = "pink"; + } + else + { + this.lteInput.style.backgroundColor = ""; + } + } + else + { + this.lteInput.style.backgroundColor = ""; + } + + return ok; + } + + #GetUrlMsgDefinition(evt, slot, type) + { + // During the slot-number migration period, keep accepting the + // historical one-based parameter names in the URL while the visible + // UI uses zero-based slot numbering. + let keySuffix = type == "user" ? "MsgDefUserDefined" : "MsgDefVendorDefined"; + let keyList = [ + `slot${slot + 1}${keySuffix}`, + ]; + + if (type == "user") + { + keyList.push(`slot${slot + 1}MsgDef`); + } + + for (let key of keyList) + { + let val = evt.Get(key, ""); + if (val !== "") + { + return val; + } + } + + return ""; + } + + #Search() + { + let msgDefinitionUserDefinedList = []; + for (let fdi of this.mdiUdList) + { + msgDefinitionUserDefinedList.push(fdi.GetMsgDefinition()); + } + + let msgDefinitionVendorDefinedList = []; + for (let fdi of this.mdiVdList) + { + msgDefinitionVendorDefinedList.push(fdi.GetMsgDefinition()); + } + + this.Emit({ + type: "SEARCH_REQUESTED", + band: this.GetBand(), + channel: this.GetChannel(), + callsign: this.GetCallsign(), + gte: this.GetGte(), + lte: this.GetLte(), + lteRaw: this.GetLteRaw(), + msgDefinitionUserDefinedList, + msgDefinitionVendorDefinedList, + }); + + this.#OnSearchStart(); + + this.refreshTimer.SetNewTimerSearchParameters(this.GetBand(), this.GetChannel(), this.GetGte(), this.GetLteRaw()); + + this.#SetStatusUpdated(); + this.#StartPeriodicStatusUpdateTimer(); + } + + #StartPeriodicStatusUpdateTimer() + { + clearInterval(this.timerId); + this.timerId = null; + + const REFRESH_INTERVAL_MS = 10000; + this.timerId = setInterval(() => { + this.#SetStatusUpdated(); + this.#SetStatusLastSeen(); + }, REFRESH_INTERVAL_MS); + } + + #SetStatusUpdated() + { + let statusUpdated = ""; + + let msUntil = this.refreshTimer.GetTimeToNextTimeoutMs(); + + if (msUntil != null) + { + // set when last update happened + let dt = utl.MakeDateTimeFromMs(utl.Now()).substr(0, 16); + let dtJustTime = dt.substr(11, 16); + + statusUpdated += `Updated ${dtJustTime}, `; + + // set when next update will happen + let nextMinStr = utl.MsToDurationStrMinutes(msUntil); + + if (nextMinStr.charAt(0) != "0") + { + statusUpdated += `next in ${nextMinStr}`; + } + else + { + statusUpdated += `next < 1 min`; + } + } + else + { + // bad search + statusUpdated = ""; + } + + // display + this.statusUpdated.innerHTML = statusUpdated; + } + + #OnDataTableRawReady(evt) + { + this.td = evt.tabularDataReadOnly; + this.#HideInitialLoadProgressDialog(); + + this.#SetStatusLastSeen(); + } + + #SetStatusLastSeen() + { + let latestAgeStr = ``; + + if (this.td != null) + { + if (this.td.Length() >= 1) + { + let dtLast = this.td.Get(0, "DateTimeLocal"); + let msLast = utl.ParseTimeToMs(dtLast); + let msNow = utl.Now(); + + let msDiff = msNow - msLast; + + let durationStr = utl.MsToDurationStrDaysHoursMinutes(msDiff); + let durationStrTrimmed = utl.DurationStrTrim(durationStr); + + latestAgeStr = `Since last seen: ${durationStrTrimmed}`; + } + else + { + latestAgeStr = `Since last seen: [none found]`; + } + } + else + { + // nothing to do + } + + this.statusLastSeen.innerHTML = latestAgeStr; + } + + #ValidateInputsAndMaybeSearch() + { + let ok = this.#ValidateInputs(); + + if (ok) + { + this.#Search(); + } + else + { + this.#OnBadSearchAttempt(); + } + + return ok; + } + + #OnBadSearchAttempt() + { + this.refreshTimer.Cancel() + this.#SetStatusUpdated(); + this.td = null; + this.#SetStatusLastSeen(); + } + + #MakeStartupGuidanceDialog() + { + let dlg = new DialogBox(); + dlg.SetTitleBar("Dashboard Help"); + + let body = dlg.GetContentContainer(); + body.style.padding = "12px"; + body.style.minWidth = "420px"; + body.style.maxWidth = "560px"; + body.style.backgroundColor = "rgb(245, 245, 245)"; + + let intro = document.createElement("div"); + intro.textContent = "Use this dashboard to search for WSPR spots and inspect the results in the map, charts, data table, and stats."; + intro.style.marginBottom = "10px"; + intro.style.fontWeight = "600"; + body.appendChild(intro); + + let p1 = document.createElement("div"); + p1.textContent = "Next steps:"; + p1.style.marginBottom = "6px"; + body.appendChild(p1); + + let ol = document.createElement("ol"); + ol.style.marginTop = "0"; + ol.style.paddingLeft = "22px"; + for (let text of [ + "Enter a callsign to search for.", + "Adjust the band, channel, and date range if needed.", + "Review or edit any custom UserDefined / VendorDefined message definitions.", + "Click Search to populate the map, charts, data table, stats.", + ]) + { + let li = document.createElement("li"); + if (text == "Adjust the band, channel, and date range if needed.") + { + li.appendChild(document.createTextNode("Adjust the band, ")); + + let a = document.createElement("a"); + a.href = "/channelmap/"; + a.target = "_blank"; + a.textContent = "channel"; + li.appendChild(a); + + li.appendChild(document.createTextNode(", and date range if needed.")); + } + else + { + li.textContent = text; + } + li.style.marginBottom = "4px"; + ol.appendChild(li); + } + body.appendChild(ol); + + let note = document.createElement("div"); + note.textContent = "If no valid search has run yet, the map can still load, but no spots will appear until you search."; + note.style.marginTop = "10px"; + body.appendChild(note); + + let buttonRow = document.createElement("div"); + buttonRow.style.marginTop = "14px"; + buttonRow.style.display = "flex"; + buttonRow.style.justifyContent = "flex-end"; + + let btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = "OK"; + btn.addEventListener("click", () => { + dlg.Hide(); + this.callsignInput?.focus?.(); + }); + buttonRow.appendChild(btn); + body.appendChild(buttonRow); + + return dlg; + } + + #MakeInitialLoadProgressModal() + { + let overlay = document.createElement("div"); + overlay.style.position = "fixed"; + overlay.style.inset = "0"; + overlay.style.display = "none"; + overlay.style.backgroundColor = "rgba(0, 0, 0, 0.18)"; + overlay.style.zIndex = "10000"; + + let panel = document.createElement("div"); + panel.style.position = "fixed"; + panel.style.boxSizing = "border-box"; + panel.style.padding = "14px"; + panel.style.minWidth = "320px"; + panel.style.maxWidth = "420px"; + panel.style.backgroundColor = "rgb(245, 245, 245)"; + panel.style.border = "1px solid rgb(160, 160, 160)"; + panel.style.boxShadow = "0 6px 22px rgba(0, 0, 0, 0.22)"; + + let intro = document.createElement("div"); + intro.textContent = "Loading dashboard results. Stand by!"; + intro.style.marginBottom = "12px"; + intro.style.fontWeight = "600"; + panel.appendChild(intro); + + let row = document.createElement("div"); + row.style.display = "flex"; + row.style.alignItems = "center"; + row.style.gap = "10px"; + row.style.marginBottom = "12px"; + + let spinner = document.createElement("div"); + spinner.style.width = "18px"; + spinner.style.height = "18px"; + spinner.style.border = "3px solid #d8d8d8"; + spinner.style.borderTop = "3px solid #3498db"; + spinner.style.borderRadius = "50%"; + spinner.style.animation = "spin 1.0s linear infinite"; + row.appendChild(spinner); + + let text = document.createElement("div"); + text.textContent = "Search is in progress."; + row.appendChild(text); + panel.appendChild(row); + + let progressTrack = document.createElement("div"); + progressTrack.style.boxSizing = "border-box"; + progressTrack.style.width = "100%"; + progressTrack.style.height = "14px"; + progressTrack.style.border = "1px solid rgb(180, 180, 180)"; + progressTrack.style.backgroundColor = "white"; + progressTrack.style.marginBottom = "8px"; + progressTrack.style.overflow = "hidden"; + + let progressFill = document.createElement("div"); + progressFill.style.height = "100%"; + progressFill.style.width = "0%"; + progressFill.style.backgroundColor = "#3498db"; + progressFill.style.transition = "width 120ms ease"; + progressTrack.appendChild(progressFill); + panel.appendChild(progressTrack); + + let progressLabel = document.createElement("div"); + progressLabel.textContent = "Searching..."; + progressLabel.style.marginBottom = "10px"; + panel.appendChild(progressLabel); + + let note = document.createElement("div"); + note.textContent = "This dialog will close automatically when the results load, or you can dismiss it now."; + panel.appendChild(note); + + let buttonRow = document.createElement("div"); + buttonRow.style.marginTop = "14px"; + buttonRow.style.display = "flex"; + buttonRow.style.justifyContent = "flex-end"; + + let btn = document.createElement("button"); + btn.type = "button"; + btn.textContent = "Dismiss"; + btn.addEventListener("click", () => { + overlay.style.display = "none"; + }); + buttonRow.appendChild(btn); + panel.appendChild(buttonRow); + + overlay.appendChild(panel); + + overlay.progressFill = progressFill; + overlay.progressLabel = progressLabel; + overlay.panel = panel; + + return overlay; + } + + #ShowStartupGuidanceDialog() + { + if (!this.startupGuidanceDialog) + { + return; + } + + this.startupGuidanceDialog.Show(); + } + + #ShowInitialLoadProgressDialog() + { + if (!this.initialLoadProgressModal || this.initialLoadProgressDialogShown) + { + return; + } + + this.#SetInitialLoadProgress(0, "Searching..."); + this.#PositionInitialLoadProgressModal(); + this.initialLoadProgressModal.style.display = "flex"; + this.initialLoadProgressDialogShown = true; + } + + #HideInitialLoadProgressDialog() + { + if (!this.initialLoadProgressModal) + { + return; + } + + this.initialLoadProgressModal.style.display = "none"; + } + + #SetInitialLoadProgress(ratio, label) + { + if (!this.initialLoadProgressModal) + { + return; + } + + let pct = Math.max(0, Math.min(100, Math.round((ratio ?? 0) * 100))); + if (this.initialLoadProgressModal.progressFill) + { + this.initialLoadProgressModal.progressFill.style.width = `${pct}%`; + } + if (this.initialLoadProgressModal.progressLabel) + { + this.initialLoadProgressModal.progressLabel.textContent = label ?? ""; + } + } + + #PositionInitialLoadProgressModal() + { + let panel = this.initialLoadProgressModal?.panel; + if (!panel) + { + return; + } + + let mapEl = this.cfg.mapContainer; + if (mapEl) + { + let rect = mapEl.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) + { + panel.style.left = `${Math.round(rect.left + rect.width / 2)}px`; + panel.style.top = `${Math.round(rect.top + rect.height / 2)}px`; + panel.style.transform = "translate(-50%, -50%)"; + return; + } + } + + panel.style.left = "50vw"; + panel.style.top = "20vh"; + panel.style.transform = "translateX(-50%)"; + } + + #OnSearchProgress(evt) + { + this.#SetInitialLoadProgress(evt.ratio ?? 0, evt.label ?? "Search is in progress."); + } + + #WireHeaderHelpLink() + { + let link = this.cfg.helpLink; + if (!link) + { + return; + } + + link.addEventListener("click", e => { + e.preventDefault(); + this.#ShowStartupGuidanceDialog(); + }); + } + + #OnSearchStart() + { + this.spinner.style.animationPlayState = "running"; + + if (this.showInitialLoadProgressOnNextSearch) + { + this.showInitialLoadProgressOnNextSearch = false; + this.#ShowInitialLoadProgressDialog(); + } + } + + #OnSearchComplete() + { + this.spinner.style.animationPlayState = "paused"; + this.#HideInitialLoadProgressDialog(); + } + + #SubmitOnEnter(e) + { + if (e.key === "Enter") + { + e.preventDefault(); + this.buttonInput.click(); + } + } + + #NoSpaces(e) + { + let retVal = true; + + if (e.which === 32) + { + e.preventDefault(); + retVal = false; + } + + return retVal; + } + + #MakeConfigurationButtonInput(type) + { + let mdiList = type == "user" ? this.mdiUdList : this.mdiVdList; + let title = type == "user" ? "Custom Message Definitions" : "Vendor Message Definitions"; + + let button = document.createElement('button'); + button.innerHTML = "⚙️"; + button.style.padding = '1px'; + + let container = document.createElement('span'); + container.appendChild(button); + + // activate dialog box + let dialogBox = new DialogBox(); + document.body.appendChild(dialogBox.GetUI()); + + dialogBox.SetTitleBar(`⚙️ ${title}`); + button.addEventListener('click', () => { + dialogBox.ToggleShowHide(); + }); + + // get place to put dialog box contents + let dbContainer = dialogBox.GetContentContainer(); + // stack inputs + dbContainer.style.display = "flex"; + dbContainer.style.flexDirection = "column"; + dbContainer.style.gap = "2px"; + dbContainer.style.maxHeight = "700px"; + + // build interface for all slots + for (let slot = 0; slot < 5; ++slot) + { + // make a collapsing title box for each input + let titleBox = new CollapsableTitleBox(); + titleBox.SetTitle(`Slot ${slot}`) + let titleBoxUi = titleBox.GetUI(); + let titleBoxContainer = titleBox.GetContentContainer(); + titleBox.SetMinWidth('813px'); + + // get a message definition input to put in the title box + let mdi = new MsgDefinitionInputUiController(); + mdiList.push(mdi); + let mdiUi = mdi.GetUI(); + mdi.SetDisplayName(`Slot${slot}`); + mdi.SetDownloadFileNamePart(`Slot${slot + 1}`); + + let suffix = ""; + + // set msg def + mdi.SetOnApplyCallback((preventUrlGet) => { + // console.log(`slot ${slot + 1} msg def updated`); + let msgDef = mdi.GetMsgDefinition(); + // console.log(msgDef); + + suffix = msgDef == "" ? "" : " (Set)"; + + titleBox.SetTitle(`Slot ${slot}${suffix}`) + + // trigger url update unless asked not to (eg by simulated call) + if (preventUrlGet !== false) + { + this.Emit("REQ_URL_GET"); + } + }); + + mdi.SetOnErrStateChangeCallback((ok) => { + if (ok == false) + { + titleBox.SetTitle(`Slot ${slot} (Error)`); + } + else + { + titleBox.SetTitle(`Slot ${slot}${suffix}`) + } + }); + + // pack the msg def input into the title box + titleBoxContainer.appendChild(mdiUi); + + // pack the titleBox into the dialog box + dbContainer.appendChild(titleBoxUi); + } + + // Create bulk import/export buttons + let buttonBar = document.createElement('div'); + let buttonFromFile = document.createElement('button'); + let buttonToFile = document.createElement('button'); + + buttonFromFile.innerHTML = "From File (Bulk)"; + buttonToFile.innerHTML = "To File (Bulk)"; + + buttonBar.appendChild(buttonFromFile); + buttonBar.appendChild(document.createTextNode(' ')); + buttonBar.appendChild(buttonToFile); + + dbContainer.appendChild(buttonBar); + + + // setup events + buttonFromFile.addEventListener('click', () => { + utl.LoadFromFile(".json").then((str) => { + let parsedObj = {}; + try + { + parsedObj = JSON.parse(str); + } + catch (e) + { + console.log(`Could not parse JSON: ${e}`); + console.log(e); + } + + for (let slot = 0; slot < 5; ++slot) + { + let mdi = mdiList[slot]; + + let slotName = `slot${slot + 1}`; + let msgDefName = `${slotName}MsgDef`; + + if (parsedObj[msgDefName] !== undefined) + { + // set value directly + mdi.SetMsgDefinition(parsedObj[msgDefName]); + + // trigger save to tracker + mdi.GetOnApplyCallback()(); + } + } + }); + }); + + buttonToFile.addEventListener('click', () => { + let fileName = `Dashboard_Configuration.json`; + + let jsonObj = {}; + + for (let slot = 0; slot < 5; ++slot) + { + let mdi = mdiList[slot]; + + let slotName = `slot${slot + 1}`; + let msgDefName = `${slotName}MsgDef`; + let jsName = `${slotName}JavaScript`; + + jsonObj[msgDefName] = mdi.GetMsgDefinition(); + } + + let value = JSON.stringify(jsonObj, null, " "); + + utl.SaveToFile(value, fileName); + }); + + + return [container, button]; + } + + #MakeBandInput() + { + let bandList = [ + "2190m", + "630m", + "160m", + "80m", + "60m", + "40m", + "30m", + "20m", + "17m", + "15m", + "12m", + "10m", + "6m", + "4m", + "2m", + "70cm", + "23cm", + ]; + + let select = document.createElement('select'); + + for (let band of bandList) + { + let option = document.createElement('option'); + + option.value = band; + + if (band == "2190m") + { + option.innerHTML = `${band} (HF)`; + } + else if (band == "630m") + { + option.innerHTML = `${band} (MF)`; + } + else + { + option.innerHTML = band; + } + + select.appendChild(option); + } + select.value = "20m"; + + let label = document.createElement('label'); + label.innerHTML = "Band "; + label.appendChild(select); + + let container = document.createElement('span'); + container.appendChild(label); + + return [container, select]; + } + + #MakeChannelInput() + { + let input = document.createElement('input'); + input.type = "number"; + input.min = 0; + input.max = 599; + input.title = "Optional, use if you have a Channel-aware tracker" + + let label = document.createElement('label'); + label.innerHTML = "Channel "; + label.appendChild(input); + + let container = document.createElement('span'); + container.title = input.title; + container.appendChild(label); + + input.addEventListener("keypress", e => this.#SubmitOnEnter(e)); + input.addEventListener("keydown", e => this.#NoSpaces(e)); + + return [container, input]; + } + + #MakeCallsignInput() + { + let input = document.createElement('input'); + input.title = "callsign"; + input.placeholder = "callsign"; + input.size = "7"; + + input.style.textTransform = "uppercase"; + + let label = document.createElement('label'); + label.innerHTML = "Callsign "; + label.appendChild(input); + + let container = document.createElement('span'); + container.title = input.title; + container.appendChild(label); + + input.addEventListener("keypress", e => this.#SubmitOnEnter(e)); + input.addEventListener("keydown", e => this.#NoSpaces(e)); + + return [container, input]; + } + + #MakeSearchButtonInput() + { + let button = document.createElement('button'); + button.innerHTML = "search"; + + return [button, button]; + } + + #MakeSpinner() + { + // Create the main spinner container + const spinnerContainer = document.createElement('div'); + spinnerContainer.className = 'spinner-container'; + + // Create the spinner element + const spinner = document.createElement('div'); + spinner.className = 'spinner'; + + // Append spinner to the container + spinnerContainer.appendChild(spinner); + + // Add CSS styles dynamically + const style = document.createElement('style'); + style.textContent = ` + .spinner-container { + display: inline-flex; + justify-content: center; + align-items: center; + position: relative; + width: 12px; + height: 12px; + } + .spinner { + width: 9px; + height: 9px; + border: 2px solid #f3f3f3; /* Light gray */ + border-top: 2px solid #3498db; /* Blue */ + border-radius: 50%; + animation: spin 1.5s linear infinite; + } + @keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } + `; + document.head.appendChild(style); + + // Return the spinner container + return [spinnerContainer, spinner]; + } + + #MakeGteInput() + { + let input = document.createElement('input'); + input.placeholder = "YYYY-MM-DD"; + input.required = true; + input.title = "Start date (required)" + input.pattern = "\d{4}-\d{2}-\d{2}"; + input.spellcheck = false; + input.size = "10"; + input.maxLength = "10"; + + let label = document.createElement('label'); + label.innerHTML = "Start "; + label.appendChild(input); + + let container = document.createElement('span'); + container.title = input.title; + container.appendChild(label); + + input.addEventListener("keypress", e => this.#SubmitOnEnter(e)); + input.addEventListener("keydown", e => this.#NoSpaces(e)); + + return [container, input]; + } + + #MakeLteInput() + { + let input = document.createElement('input'); + input.placeholder = "YYYY-MM-DD"; + input.required = true; + input.title = "End date (optional)" + input.pattern = "\d{4}-\d{2}-\d{2}"; + input.spellcheck = false; + input.size = "10"; + input.maxLength = "10"; + + let label = document.createElement('label'); + label.innerHTML = "End "; + label.appendChild(input); + + let container = document.createElement('span'); + container.title = input.title; + container.appendChild(label); + + input.addEventListener("keypress", e => this.#SubmitOnEnter(e)); + input.addEventListener("keydown", e => this.#NoSpaces(e)); + + return [container, input]; + } + + #MakeUI() + { + this.domContainer = document.createElement('span'); + + // create + let [buttonConfigContainer, buttonConfig] = this.#MakeConfigurationButtonInput("user"); + let [buttonConfigProContainer, buttonProConfig] = this.#MakeConfigurationButtonInput("vendor"); + let [bandSelectInputContainer, bandSelectInput] = this.#MakeBandInput(); + let [channelInputContainer, channelInput] = this.#MakeChannelInput(); + let [callsignInputContainer, callsignInput] = this.#MakeCallsignInput(); + let [buttonInputContainer, buttonInput] = this.#MakeSearchButtonInput(); + let [spinnerContainer, spinner] = this.#MakeSpinner(); + let [gteInputContainer, gteInput] = this.#MakeGteInput(); + let [lteInputContainer, lteInput] = this.#MakeLteInput(); + + this.statusUpdated = document.createElement('span'); + this.statusLastSeen = document.createElement('span'); + + // keep the spinner paused to start + spinner.style.animationPlayState = "paused"; + + + + // assemble + let container = document.createElement('span'); + + container.appendChild(buttonConfigContainer); + container.appendChild(document.createTextNode(" ")); + container.appendChild(buttonConfigProContainer); + buttonConfigProContainer.appendChild(document.createTextNode(" ")); + container.appendChild(bandSelectInputContainer); + container.appendChild(document.createTextNode(" ")); + container.appendChild(channelInputContainer); + container.appendChild(document.createTextNode(" ")); + container.appendChild(callsignInputContainer); + container.appendChild(document.createTextNode(" ")); + container.appendChild(buttonInputContainer); + container.appendChild(document.createTextNode(" ")); + + container.appendChild(spinnerContainer); + container.appendChild(document.createTextNode(" ")); + + container.appendChild(gteInputContainer); + container.appendChild(document.createTextNode(" ")); + container.appendChild(lteInputContainer); + container.appendChild(document.createTextNode(" | ")); + container.appendChild(this.statusUpdated); + container.appendChild(document.createTextNode(" | ")); + container.appendChild(this.statusLastSeen); + + // styling + buttonConfigProContainer.style.display = "none"; + + // events + document.addEventListener("keydown", (e) => { + let retVal = true; + let keyLower = String(e.key || "").toLowerCase(); + + if (e.key == "?" && e.target == document.body) + { + e.preventDefault(); + retVal = false; + + if (buttonConfigProContainer.style.display == "none") + { + buttonConfigProContainer.style.display = ""; + } + else + { + buttonConfigProContainer.style.display = "none"; + } + } + else if (keyLower == "r" && e.target == document.body) + { + e.preventDefault(); + retVal = false; + + // Match the timer refresh behavior exactly. + this.#Search(); + } + + return retVal; + }); + + // capture + this.buttonConfig = buttonConfig; + this.bandSelect = bandSelectInput; + this.channelInput = channelInput; + this.callsignInput = callsignInput; + this.buttonInput = buttonInput; + this.spinner = spinner; + this.gteInput = gteInput; + this.lteInput = lteInput; + + this.ui = container; + + return this.ui; + } +} + + diff --git a/js/WsprSearchUiMapController.js b/js/WsprSearchUiMapController.js new file mode 100644 index 0000000..9a43752 --- /dev/null +++ b/js/WsprSearchUiMapController.js @@ -0,0 +1,259 @@ +/* +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 { SpotMapAsyncLoader } from './SpotMapAsyncLoader.js'; +import { TabularData } from '../../../../js/TabularData.js'; +import { WSPREncoded } from '/js/WSPREncoded.js'; +import { WsprSearchUiDataTableVisibility } from './WsprSearchUiDataTableVisibility.js'; +import { WsprSearchUiDataTableColumnOrder } from './WsprSearchUiDataTableColumnOrder.js'; + + +export class WsprSearchUiMapController +extends Base +{ + constructor(cfg) + { + super(); + + this.cfg = cfg; + this.td = null; + this.mapDataToken = 0; + this.showEmptyMapRequested = false; + this.showControlHelpRequested = false; + + this.ok = this.cfg.container; + + // map gets async loaded + this.mapModule = null; + this.map = null; + SpotMapAsyncLoader.SetOnLoadCallback((module) => { + this.mapModule = module; + + this.map = new this.mapModule.SpotMap({ + container: this.ui, + }); + + this.map.SetDebug(this.debug); + + if (this.showEmptyMapRequested) + { + this.map.SetSpotList([]); + this.showEmptyMapRequested = false; + } + else if (this.td) + { + this.ScheduleMapData(); + } + + if (this.showControlHelpRequested) + { + this.map.ShowControlHelpDialog(); + this.showControlHelpRequested = false; + } + }); + + if (this.ok) + { + this.ui = this.MakeUI(); + this.cfg.container.appendChild(this.ui); + } + } + + SetDebug(tf) + { + super.SetDebug(tf); + + this.t.SetCcGlobal(tf); + } + + OnEvent(evt) + { + if (this.ok) + { + switch (evt.type) { + case "DATA_TABLE_RAW_READY": this.OnDataTableRawReady(evt); break; + case "DATA_TABLE_VISIBILITY_CHANGED": this.OnDataTableVisibilityChanged(evt); break; + case "SHOW_EMPTY_MAP": this.OnShowEmptyMap(evt); break; + case "SHOW_MAP_CONTROL_HELP": this.OnShowMapControlHelp(evt); break; + } + } + } + + OnDataTableRawReady(evt) + { + // cache data + this.td = evt.tabularDataReadOnly; + + // check if we can map immediately + if (this.mapModule != null) + { + this.ScheduleMapData(); + } + } + + OnDataTableVisibilityChanged(evt) + { + if (this.td && this.mapModule != null) + { + this.ScheduleMapData(); + } + } + + OnShowEmptyMap(evt) + { + this.td = null; + + if (this.map) + { + this.map.SetSpotList([]); + } + else + { + this.showEmptyMapRequested = true; + } + } + + OnShowMapControlHelp(evt) + { + if (this.map) + { + this.map.ShowControlHelpDialog(); + } + else + { + this.showControlHelpRequested = true; + } + } + + ScheduleMapData() + { + let token = ++this.mapDataToken; + + let run = () => { + if (token != this.mapDataToken) + { + return; + } + + this.MapData(); + }; + + if (window.requestIdleCallback) + { + window.requestIdleCallback(() => { + window.requestAnimationFrame(run); + }, { timeout: 250 }); + } + else + { + window.setTimeout(() => { + window.requestAnimationFrame(run); + }, 0); + } + } + + MapData() + { + this.t.Reset(); + this.t.Event(`WsprSearchUiMapController::MapData Start`); + + let spotList = []; + if (this.td.Idx("Lat") != undefined && this.td.Idx("Lng") != undefined) + { + this.td.ForEach(row => { + let metaData = this.td.GetRowMetaData(row); + let locationSource = metaData?.overlap?.resolved?.sourceByFamily?.location ?? null; + let latResolved = this.td.Idx("Lat") != undefined ? this.td.Get(row, "Lat") : null; + let lngResolved = this.td.Idx("Lng") != undefined ? this.td.Get(row, "Lng") : null; + + let lat = null; + let lng = null; + + if (latResolved != null && lngResolved != null) + { + lat = latResolved; + lng = lngResolved; + } + + if (lat != null && lng != null) + { + // get a list of all the reporting stations + let seenDataList = []; + for (let msg of metaData.slotMsgList) + { + if (msg) + { + for (let rxRecord of msg.rxRecordList) + { + let [lat, lng] = WSPREncoded.DecodeMaidenheadToDeg(rxRecord.rxGrid); + + let seenData = { + sign: rxRecord.callsign, + lat, + lng, + grid: rxRecord.rxGrid, + }; + + seenDataList.push(seenData); + } + } + } + + // send along a cut-down version of the data available + let tdSpot = new TabularData(this.td.MakeDataTableFromRow(row)); + let popupVisibleColSet = new Set([ + ...WsprSearchUiDataTableVisibility.GetVisibleColumnsForStorageKey("dateTimeVisible"), + ...WsprSearchUiDataTableVisibility.GetVisibleColumnsForStorageKey("resolvedVisible"), + ]); + let popupColList = tdSpot.GetHeaderList().filter(col => popupVisibleColSet.has(col)); + tdSpot.SetColumnOrder(popupColList); + WsprSearchUiDataTableColumnOrder.Apply(tdSpot); + tdSpot.DeleteEmptyColumns(); + + let spot = new this.mapModule.Spot({ + lat: lat, + lng: lng, + grid: null, + accuracy: + (locationSource == "HRL" || locationSource == "EBT") ? "veryHigh" : + (locationSource == "BT") ? "high" : + "low", + dtLocal: tdSpot.Get(0, "DateTimeLocal"), + td: tdSpot, + seenDataList: seenDataList, + }); + + spotList.push(spot); + } + }, true); + } + + // hand off even an empty spot list + this.map.SetSpotList(spotList); + + this.t.Event(`WsprSearchUiMapController::MapData End`); + } + + MakeUI() + { + let ui = document.createElement("div"); + + ui.style.boxSizing = "border-box"; + ui.style.border = "1px solid black"; + ui.style.width = "1210px"; + ui.style.height = "550px"; + + ui.style.resize = "both"; + ui.style.overflow = "hidden"; + + return ui; + } +} diff --git a/js/WsprSearchUiStatsFilterController.js b/js/WsprSearchUiStatsFilterController.js new file mode 100644 index 0000000..424183e --- /dev/null +++ b/js/WsprSearchUiStatsFilterController.js @@ -0,0 +1,593 @@ +/* +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 { Base } from './Base.js'; +import { StrAccumulator } from '/js/Utl.js'; +import { AsyncResourceLoader } from './AsyncResourceLoader.js'; + +export class WsprSearchUiStatsFilterController +extends Base +{ + static urlEchartsScript = `https://fastly.jsdelivr.net/npm/echarts@5.4.2/dist/echarts.min.js`; + + constructor(cfg) + { + super(); + + this.cfg = cfg; + + this.ok = + this.cfg.container && + this.cfg.wsprSearch; + + this.chart = null; + this.chartReady = false; + this.chartLoadPromise = null; + this.renderSankeyToken = 0; + + if (this.ok) + { + this.ui = this.MakeUI(); + this.cfg.container.appendChild(this.ui); + this.EnsureChartReady(); + } + else + { + this.Err(`WsprSearchUiStatsFilterController`, `Could not init`); + } + } + + OnEvent(evt) + { + if (this.ok) + { + switch (evt.type) { + case "SEARCH_COMPLETE": this.OnSearchComplete(); break; + } + } + } + + async OnSearchComplete() + { + let slotStatsList = this.ComputeSlotStats(); + this.RenderText(slotStatsList); + this.ScheduleRenderSankey(slotStatsList); + } + + GetFlowSpecList() + { + return [ + { label: "S0 Reg", slot: 0, type: "regular" }, + { label: "S0 ET", slot: 0, type: "telemetry" }, + { label: "S1 BT", slot: 1, telemetryType: "basic" }, + { label: "S1 ET", slot: 1, telemetryType: "extended" }, + { label: "S2 ET", slot: 2 }, + { label: "S3 ET", slot: 3 }, + { label: "S4 ET", slot: 4 }, + ]; + } + + MsgMatchesFlowSpec(msg, flowSpec) + { + if (!flowSpec.type) + { + if (!flowSpec.telemetryType) + { + return true; + } + } + + if (flowSpec.telemetryType == "basic") + { + return msg.IsTelemetryBasic(); + } + + if (flowSpec.telemetryType == "extended") + { + return msg.IsTelemetryExtended(); + } + + return msg.type == flowSpec.type; + } + + ComputeSlotStats() + { + let flowSpecList = this.GetFlowSpecList(); + let slotStatsList = []; + for (const flowSpec of flowSpecList) + { + slotStatsList.push({ + label: flowSpec.label, + flowSpec: flowSpec, + input: 0, + rejectedBadTelemetry: 0, + rejectedBySpec: 0, + rejectedByFingerprinting: 0, + finalCandidate: 0, + outcomeZeroCandidates: 0, + outcomeOneCandidate: 0, + outcomeMultiCandidate: 0, + }); + } + + this.cfg.wsprSearch.ForEachWindowMsgListList(msgListList => { + for (let idx = 0; idx < slotStatsList.length; ++idx) + { + let s = slotStatsList[idx]; + let msgList = msgListList[s.flowSpec.slot]; + let msgListFiltered = msgList.filter(msg => this.MsgMatchesFlowSpec(msg, s.flowSpec)); + + for (const msg of msgListFiltered) + { + s.input += 1; + + if (msg.IsCandidate()) + { + s.finalCandidate += 1; + continue; + } + + let rejectType = this.GetRejectType(msg); + if (rejectType == "ByBadTelemetry") + { + s.rejectedBadTelemetry += 1; + } + else if (rejectType == "BySpec") + { + s.rejectedBySpec += 1; + } + else if (rejectType == "ByFingerprinting") + { + s.rejectedByFingerprinting += 1; + } + } + + let candidateCount = 0; + for (const msg of msgListFiltered) + { + if (msg.IsCandidate()) + { + candidateCount += 1; + } + } + + if (candidateCount == 0) + { + // Keep Sankey math consistent (message units across all stages): + // zero-candidate windows contribute zero candidate messages. + s.outcomeZeroCandidates += 0; + } + else if (candidateCount == 1) + { + s.outcomeOneCandidate += 1; + } + else + { + // Multi-candidate windows contribute all remaining candidate messages. + s.outcomeMultiCandidate += candidateCount; + } + } + }); + + for (const s of slotStatsList) + { + s.afterBadTelemetry = s.input - s.rejectedBadTelemetry; + s.afterBySpec = s.afterBadTelemetry - s.rejectedBySpec; + s.afterByFingerprinting = s.afterBySpec - s.rejectedByFingerprinting; + if (s.afterByFingerprinting < 0) + { + s.afterByFingerprinting = 0; + } + } + + return slotStatsList; + } + + GetRejectType(msg) + { + let auditList = msg.candidateFilterAuditList || []; + if (auditList.length == 0) + { + return ""; + } + + // By design, first reject in pipeline determines final state. + return auditList[0].type || ""; + } + + RenderText(slotStatsList) + { + let a = new StrAccumulator(); + let fmtPct = (num, den) => { + let pct = 0; + if (den > 0) + { + pct = Math.round((num / den) * 100); + } + + return `(${pct.toString().padStart(3)}%)`; + }; + + a.A(`Filter Stats (Per Slot)`); + a.A(`-----------------------`); + for (let slot = 0; slot < slotStatsList.length; ++slot) + { + let s = slotStatsList[slot]; + let valueWidth = Math.max( + 1, + s.input.toString().length, + s.rejectedBadTelemetry.toString().length, + s.rejectedBySpec.toString().length, + s.rejectedByFingerprinting.toString().length, + s.finalCandidate.toString().length, + s.outcomeZeroCandidates.toString().length, + s.outcomeOneCandidate.toString().length, + s.outcomeMultiCandidate.toString().length, + ); + let fmtVal = (v) => v.toString().padStart(valueWidth); + + a.A(`${s.label}`); + a.A(` Input : ${s.input}`); + a.A(` Rejected BadTelemetry : ${fmtVal(s.rejectedBadTelemetry)} ${fmtPct(s.rejectedBadTelemetry, s.input)}`); + a.A(` Rejected BySpec : ${fmtVal(s.rejectedBySpec)} ${fmtPct(s.rejectedBySpec, s.input)}`); + a.A(` Rejected Fingerprint : ${fmtVal(s.rejectedByFingerprinting)} ${fmtPct(s.rejectedByFingerprinting, s.input)}`); + a.A(` Final Candidate : ${fmtVal(s.finalCandidate)} ${fmtPct(s.finalCandidate, s.input)}`); + a.A(` Outcome: 0 candidate : ${fmtVal(s.outcomeZeroCandidates)} ${fmtPct(s.outcomeZeroCandidates, s.input)}`); + a.A(` Outcome: 1 candidate : ${fmtVal(s.outcomeOneCandidate)} ${fmtPct(s.outcomeOneCandidate, s.input)}`); + a.A(` Outcome: 2+ candidates: ${fmtVal(s.outcomeMultiCandidate)} ${fmtPct(s.outcomeMultiCandidate, s.input)}`); + } + + this.ta.value = a.Get(); + } + + async RenderSankey(slotStatsList) + { + await this.EnsureChartReady(); + if (!this.chartReady) + { + return; + } + + let nodeMap = new Map(); + let links = []; + + let addNode = (name, depth = undefined) => { + if (!nodeMap.has(name)) + { + let node = { name }; + if (depth != undefined) + { + node.depth = depth; + } + nodeMap.set(name, node); + return; + } + + // Keep the earliest stage depth if the node was already created. + if (depth != undefined) + { + let node = nodeMap.get(name); + if (node.depth == undefined || depth < node.depth) + { + node.depth = depth; + } + } + }; + + let addLink = (source, target, value) => { + if (value <= 0) + { + return; + } + + addNode(source); + addNode(target); + links.push({ source, target, value }); + }; + + for (let slot = 0; slot < slotStatsList.length; ++slot) + { + let s = slotStatsList[slot]; + let prefix = s.label; + + let nInput = `${prefix}`; + let nAfterBad = `${prefix} After BadTelemetry`; + let nAfterSpec = `${prefix} After BySpec`; + let nAfterFp = `${prefix} After ByFingerprinting`; + + let nRejBad = `${prefix} Rejected BadTelemetry`; + let nRejSpec = `${prefix} Rejected BySpec`; + let nRejFp = `${prefix} Rejected ByFingerprinting`; + let nOutcomeZero = `${prefix} Outcome: 0 Candidate`; + let nOutcomeOne = `${prefix} Outcome: 1 Candidate`; + let nOutcomeMulti = `${prefix} Outcome: 2+ Candidates`; + + addNode(nInput, 0); + addNode(nRejBad, 1); + addNode(nAfterBad, 1); + addNode(nRejSpec, 2); + addNode(nAfterSpec, 2); + addNode(nRejFp, 3); + addNode(nAfterFp, 3); + addNode(nOutcomeZero, 4); + addNode(nOutcomeOne, 4); + addNode(nOutcomeMulti, 4); + + addLink(nInput, nRejBad, s.rejectedBadTelemetry); + addLink(nInput, nAfterBad, s.afterBadTelemetry); + + addLink(nAfterBad, nRejSpec, s.rejectedBySpec); + addLink(nAfterBad, nAfterSpec, s.afterBySpec); + + addLink(nAfterSpec, nRejFp, s.rejectedByFingerprinting); + addLink(nAfterSpec, nAfterFp, s.afterByFingerprinting); + + addLink(nAfterFp, nOutcomeZero, s.outcomeZeroCandidates); + addLink(nAfterFp, nOutcomeOne, s.outcomeOneCandidate); + addLink(nAfterFp, nOutcomeMulti, s.outcomeMultiCandidate); + } + + this.chart.setOption({ + title: { + text: "Filter Pipeline", + left: "left", + }, + tooltip: { + trigger: "item", + triggerOn: "mousemove", + }, + series: [ + { + type: "sankey", + // Use automatic Sankey layout so each phase is positioned by graph depth. + layoutIterations: 64, + nodeAlign: "justify", + nodeGap: 16, + emphasis: { + // Custom hover behavior below handles upstream-only highlighting. + focus: "none", + }, + data: Array.from(nodeMap.values()), + links: links, + lineStyle: { + color: "source", + curveness: 0.5, + }, + }, + ], + animation: false, + }, true); + + this.#InstallUpstreamHover(nodeMap, links); + } + + ScheduleRenderSankey(slotStatsList) + { + let token = ++this.renderSankeyToken; + + let run = async () => { + if (token != this.renderSankeyToken) + { + return; + } + + await this.RenderSankey(slotStatsList); + }; + + if (window.requestIdleCallback) + { + window.requestIdleCallback(() => { + window.requestAnimationFrame(() => { + run(); + }); + }, { timeout: 250 }); + } + else + { + window.setTimeout(() => { + window.requestAnimationFrame(() => { + run(); + }); + }, 0); + } + } + + #InstallUpstreamHover(nodeMap, links) + { + if (!this.chart) + { + return; + } + + let nodeNameList = Array.from(nodeMap.keys()); + let nodeNameSet = new Set(nodeNameList); + let nodeIdxByName = new Map(); + nodeNameList.forEach((name, idx) => nodeIdxByName.set(name, idx)); + + let incomingEdgeIdxByTarget = new Map(); + for (let i = 0; i < links.length; ++i) + { + let l = links[i]; + if (!incomingEdgeIdxByTarget.has(l.target)) + { + incomingEdgeIdxByTarget.set(l.target, []); + } + incomingEdgeIdxByTarget.get(l.target).push(i); + } + + // Track highlighted items so we can downplay cleanly. + this.sankeyHoverState = { + nodeIdxSet: new Set(), + edgeIdxSet: new Set(), + }; + + let clearHighlight = () => { + if (!this.sankeyHoverState) + { + return; + } + + for (const idx of this.sankeyHoverState.nodeIdxSet) + { + this.chart.dispatchAction({ + type: "downplay", + seriesIndex: 0, + dataType: "node", + dataIndex: idx, + }); + } + for (const idx of this.sankeyHoverState.edgeIdxSet) + { + this.chart.dispatchAction({ + type: "downplay", + seriesIndex: 0, + dataType: "edge", + dataIndex: idx, + }); + } + + this.sankeyHoverState.nodeIdxSet.clear(); + this.sankeyHoverState.edgeIdxSet.clear(); + }; + + let applyUpstreamHighlight = (seedNameList) => { + clearHighlight(); + + let seenNameSet = new Set(); + let stack = [...seedNameList]; + while (stack.length) + { + let cur = stack.pop(); + if (!cur || seenNameSet.has(cur)) + { + continue; + } + seenNameSet.add(cur); + + let nodeIdx = nodeIdxByName.get(cur); + if (nodeIdx != undefined) + { + this.sankeyHoverState.nodeIdxSet.add(nodeIdx); + } + + let edgeIdxList = incomingEdgeIdxByTarget.get(cur) || []; + for (const edgeIdx of edgeIdxList) + { + this.sankeyHoverState.edgeIdxSet.add(edgeIdx); + let src = links[edgeIdx].source; + if (src && !seenNameSet.has(src)) + { + stack.push(src); + } + } + } + + for (const idx of this.sankeyHoverState.nodeIdxSet) + { + this.chart.dispatchAction({ + type: "highlight", + seriesIndex: 0, + dataType: "node", + dataIndex: idx, + }); + } + for (const idx of this.sankeyHoverState.edgeIdxSet) + { + this.chart.dispatchAction({ + type: "highlight", + seriesIndex: 0, + dataType: "edge", + dataIndex: idx, + }); + } + }; + + this.chart.off("mouseover"); + this.chart.off("globalout"); + + this.chart.on("mouseover", (params) => { + if (!params || params.seriesType != "sankey") + { + return; + } + + if (params.dataType == "node") + { + let name = params?.data?.name; + if (nodeNameSet.has(name)) + { + applyUpstreamHighlight([name]); + } + } + else if (params.dataType == "edge") + { + // Upstream of an edge means upstream of its target. + let target = params?.data?.target; + if (nodeNameSet.has(target)) + { + applyUpstreamHighlight([target]); + } + } + }); + + this.chart.on("globalout", () => { + clearHighlight(); + }); + } + + async EnsureChartReady() + { + if (this.chartReady) + { + return; + } + + if (!this.chartLoadPromise) + { + this.chartLoadPromise = AsyncResourceLoader.AsyncLoadScript(WsprSearchUiStatsFilterController.urlEchartsScript); + } + + try + { + await this.chartLoadPromise; + if (!this.chart) + { + this.chart = echarts.init(this.chartDiv); + } + this.chartReady = true; + } + catch (e) + { + this.Err(`WsprSearchUiStatsFilterController`, `Could not init chart: ${e}`); + } + } + + MakeUI() + { + let ui = document.createElement('div'); + + this.ta = document.createElement('textarea'); + this.ta.spellcheck = "false"; + this.ta.readOnly = true; + this.ta.disabled = true; + this.ta.style.width = "600px"; + this.ta.style.height = "260px"; + + this.chartDiv = document.createElement('div'); + this.chartDiv.style.boxSizing = "border-box"; + this.chartDiv.style.border = "1px solid black"; + this.chartDiv.style.width = "1210px"; + this.chartDiv.style.height = "800px"; + this.chartDiv.style.marginTop = "8px"; + + ui.appendChild(this.ta); + ui.appendChild(this.chartDiv); + + return ui; + } +} diff --git a/js/WsprSearchUiStatsSearchController.js b/js/WsprSearchUiStatsSearchController.js new file mode 100644 index 0000000..3158e9a --- /dev/null +++ b/js/WsprSearchUiStatsSearchController.js @@ -0,0 +1,104 @@ +/* +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 { Base } from './Base.js'; +import { StrAccumulator } from '/js/Utl.js'; + + +export class WsprSearchUiStatsSearchController +extends Base +{ + constructor(cfg) + { + super(); + + this.cfg = cfg; + + this.ok = + this.cfg.container && + this.cfg.wsprSearch; + + if (this.ok) + { + this.ui = this.MakeUI(); + this.cfg.container.appendChild(this.ui); + } + else + { + this.Err(`WsprSearchUiStatsSearchController`, `Could not init`); + console.log(this.cfg.container); + console.log(this.cfg.wsprSearch); + } + } + + OnEvent(evt) + { + if (this.ok) + { + switch (evt.type) { + case "SEARCH_COMPLETE": this.OnSearchComplete(); break; + } + } + } + + OnSearchComplete() + { + let stats = this.cfg.wsprSearch.GetStats(); + + let a = new StrAccumulator(); + + a.A(`Querying`); + a.A(`--------`); + a.A(` Slot 0 Regular - ms: ${stats.query.slot0Regular.durationMs.toString().padStart(4)}, rows: ${stats.query.slot0Regular.rowCount.toString().padStart(5)}, msgs: ${stats.query.slot0Regular.uniqueMsgCount.toString().padStart(4)}`); + a.A(` Slot 0 Telemetry - ms: ${stats.query.slot0Telemetry.durationMs.toString().padStart(4)}, rows: ${stats.query.slot0Telemetry.rowCount.toString().padStart(5)}, msgs: ${stats.query.slot0Telemetry.uniqueMsgCount.toString().padStart(4)}`); + a.A(` Slot 1 Telemetry - ms: ${stats.query.slot1Telemetry.durationMs.toString().padStart(4)}, rows: ${stats.query.slot1Telemetry.rowCount.toString().padStart(5)}, msgs: ${stats.query.slot1Telemetry.uniqueMsgCount.toString().padStart(4)}`); + a.A(` Slot 2 Telemetry - ms: ${stats.query.slot2Telemetry.durationMs.toString().padStart(4)}, rows: ${stats.query.slot2Telemetry.rowCount.toString().padStart(5)}, msgs: ${stats.query.slot2Telemetry.uniqueMsgCount.toString().padStart(4)}`); + a.A(` Slot 3 Telemetry - ms: ${stats.query.slot3Telemetry.durationMs.toString().padStart(4)}, rows: ${stats.query.slot3Telemetry.rowCount.toString().padStart(5)}, msgs: ${stats.query.slot3Telemetry.uniqueMsgCount.toString().padStart(4)}`); + a.A(` Slot 4 Telemetry - ms: ${stats.query.slot4Telemetry.durationMs.toString().padStart(4)}, rows: ${stats.query.slot4Telemetry.rowCount.toString().padStart(5)}, msgs: ${stats.query.slot4Telemetry.uniqueMsgCount.toString().padStart(4)}`); + a.A(``); + a.A(`Processing`); + a.A(`----------`); + a.A(`SearchTotalMs : ${stats.processing.searchTotalMs.toString().padStart(4)}`); + a.A(` DecodeMs : ${stats.processing.decodeMs.toString().padStart(4)}`); + a.A(` FilterMs : ${stats.processing.filterMs.toString().padStart(4)}`); + a.A(` DataTableMs : ${stats.processing.dataTableBuildMs.toString().padStart(4)}`); + a.A(` UiRenderMs : ${stats.processing.uiRenderMs.toString().padStart(4)}`); + a.A(` StatsGatherMs: ${stats.processing.statsGatherMs.toString().padStart(4)}`); + a.A(``); + a.A(`Results`); + a.A(`-------`); + a.A(` Total 10-min windows: ${stats.results.windowCount}`); + a.A(` Slot 0 - msgs: ${stats.results.slot0.haveAnyMsgsPct.toString().padStart(3)} %, 0 ok pct: ${stats.results.slot0.noCandidatePct.toString().padStart(3)} %, 1 ok pct: ${stats.results.slot0.oneCandidatePct.toString().padStart(3)} %, 2+ ok pct: ${stats.results.slot0.multiCandidatePct.toString().padStart(3)} %`); + a.A(` Slot 1 - msgs: ${stats.results.slot1.haveAnyMsgsPct.toString().padStart(3)} %, 0 ok pct: ${stats.results.slot1.noCandidatePct.toString().padStart(3)} %, 1 ok pct: ${stats.results.slot1.oneCandidatePct.toString().padStart(3)} %, 2+ ok pct: ${stats.results.slot1.multiCandidatePct.toString().padStart(3)} %`); + a.A(` Slot 2 - msgs: ${stats.results.slot2.haveAnyMsgsPct.toString().padStart(3)} %, 0 ok pct: ${stats.results.slot2.noCandidatePct.toString().padStart(3)} %, 1 ok pct: ${stats.results.slot2.oneCandidatePct.toString().padStart(3)} %, 2+ ok pct: ${stats.results.slot2.multiCandidatePct.toString().padStart(3)} %`); + a.A(` Slot 3 - msgs: ${stats.results.slot3.haveAnyMsgsPct.toString().padStart(3)} %, 0 ok pct: ${stats.results.slot3.noCandidatePct.toString().padStart(3)} %, 1 ok pct: ${stats.results.slot3.oneCandidatePct.toString().padStart(3)} %, 2+ ok pct: ${stats.results.slot3.multiCandidatePct.toString().padStart(3)} %`); + a.A(` Slot 4 - msgs: ${stats.results.slot4.haveAnyMsgsPct.toString().padStart(3)} %, 0 ok pct: ${stats.results.slot4.noCandidatePct.toString().padStart(3)} %, 1 ok pct: ${stats.results.slot4.oneCandidatePct.toString().padStart(3)} %, 2+ ok pct: ${stats.results.slot4.multiCandidatePct.toString().padStart(3)} %`); + + this.ta.value = a.Get(); + } + + MakeUI() + { + let ui = document.createElement('div'); + + let ta = document.createElement('textarea'); + ta.spellcheck = "false"; + ta.readOnly = true; + ta.disabled = true; + ta.style.width = "600px"; + ta.style.height = "400px"; + + this.ta = ta; + + ui.appendChild(ta); + + return ui; + } +} + + diff --git a/js/dat.gui.min.js b/js/dat.gui.min.js new file mode 100644 index 0000000..e69de29 diff --git a/js/echarts.min.js b/js/echarts.min.js new file mode 100644 index 0000000..e69de29 diff --git a/js/ol.js b/js/ol.js new file mode 100644 index 0000000..e69de29 diff --git a/js/suncalc.js b/js/suncalc.js new file mode 100644 index 0000000..e7c97d0 --- /dev/null +++ b/js/suncalc.js @@ -0,0 +1,325 @@ +/* +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.) +*/ + +/* + (c) 2011-2015, Vladimir Agafonkin + SunCalc is a JavaScript library for calculating sun/moon position and light phases. + https://github.com/mourner/suncalc +*/ + +(function () { 'use strict'; + +// shortcuts for easier to read formulas + +var PI = Math.PI, + sin = Math.sin, + cos = Math.cos, + tan = Math.tan, + asin = Math.asin, + atan = Math.atan2, + acos = Math.acos, + rad = PI / 180; + +// sun calculations are based on http://aa.quae.nl/en/reken/zonpositie.html formulas + + +// date/time constants and conversions + +var dayMs = 1000 * 60 * 60 * 24, + J1970 = 2440588, + J2000 = 2451545; + +function toJulian(date) { return date.valueOf() / dayMs - 0.5 + J1970; } +function fromJulian(j) { return new Date((j + 0.5 - J1970) * dayMs); } +function toDays(date) { return toJulian(date) - J2000; } + + +// general calculations for position + +var e = rad * 23.4397; // obliquity of the Earth + +function rightAscension(l, b) { return atan(sin(l) * cos(e) - tan(b) * sin(e), cos(l)); } +function declination(l, b) { return asin(sin(b) * cos(e) + cos(b) * sin(e) * sin(l)); } + +function azimuth(H, phi, dec) { return atan(sin(H), cos(H) * sin(phi) - tan(dec) * cos(phi)); } +function altitude(H, phi, dec) { return asin(sin(phi) * sin(dec) + cos(phi) * cos(dec) * cos(H)); } + +function siderealTime(d, lw) { return rad * (280.16 + 360.9856235 * d) - lw; } + +function astroRefraction(h) { + if (h < 0) // the following formula works for positive altitudes only. + h = 0; // if h = -0.08901179 a div/0 would occur. + + // formula 16.4 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998. + // 1.02 / tan(h + 10.26 / (h + 5.10)) h in degrees, result in arc minutes -> converted to rad: + return 0.0002967 / Math.tan(h + 0.00312536 / (h + 0.08901179)); +} + +// general sun calculations + +function solarMeanAnomaly(d) { return rad * (357.5291 + 0.98560028 * d); } + +function eclipticLongitude(M) { + + var C = rad * (1.9148 * sin(M) + 0.02 * sin(2 * M) + 0.0003 * sin(3 * M)), // equation of center + P = rad * 102.9372; // perihelion of the Earth + + return M + C + P + PI; +} + +function sunCoords(d) { + + var M = solarMeanAnomaly(d), + L = eclipticLongitude(M); + + return { + dec: declination(L, 0), + ra: rightAscension(L, 0) + }; +} + + +var SunCalc = {}; + + +// calculates sun position for a given date and latitude/longitude + +SunCalc.getPosition = function (date, lat, lng) { + + var lw = rad * -lng, + phi = rad * lat, + d = toDays(date), + + c = sunCoords(d), + H = siderealTime(d, lw) - c.ra; + + return { + azimuth: azimuth(H, phi, c.dec), + altitude: altitude(H, phi, c.dec) + }; +}; + + +// sun times configuration (angle, morning name, evening name) + +var times = SunCalc.times = [ + [-0.833, 'sunrise', 'sunset' ], + [ -0.3, 'sunriseEnd', 'sunsetStart' ], + [ -6, 'dawn', 'dusk' ], + [ -12, 'nauticalDawn', 'nauticalDusk'], + [ -18, 'nightEnd', 'night' ], + [ 6, 'goldenHourEnd', 'goldenHour' ] +]; + +// adds a custom time to the times config + +SunCalc.addTime = function (angle, riseName, setName) { + times.push([angle, riseName, setName]); +}; + + +// calculations for sun times + +var J0 = 0.0009; + +function julianCycle(d, lw) { return Math.round(d - J0 - lw / (2 * PI)); } + +function approxTransit(Ht, lw, n) { return J0 + (Ht + lw) / (2 * PI) + n; } +function solarTransitJ(ds, M, L) { return J2000 + ds + 0.0053 * sin(M) - 0.0069 * sin(2 * L); } + +function hourAngle(h, phi, d) { return acos((sin(h) - sin(phi) * sin(d)) / (cos(phi) * cos(d))); } +function observerAngle(height) { return -2.076 * Math.sqrt(height) / 60; } + +// returns set time for the given sun altitude +function getSetJ(h, lw, phi, dec, n, M, L) { + + var w = hourAngle(h, phi, dec), + a = approxTransit(w, lw, n); + return solarTransitJ(a, M, L); +} + + +// calculates sun times for a given date, latitude/longitude, and, optionally, +// the observer height (in meters) relative to the horizon + +SunCalc.getTimes = function (date, lat, lng, height) { + + height = height || 0; + + var lw = rad * -lng, + phi = rad * lat, + + dh = observerAngle(height), + + d = toDays(date), + n = julianCycle(d, lw), + ds = approxTransit(0, lw, n), + + M = solarMeanAnomaly(ds), + L = eclipticLongitude(M), + dec = declination(L, 0), + + Jnoon = solarTransitJ(ds, M, L), + + i, len, time, h0, Jset, Jrise; + + + var result = { + solarNoon: fromJulian(Jnoon), + nadir: fromJulian(Jnoon - 0.5) + }; + + for (i = 0, len = times.length; i < len; i += 1) { + time = times[i]; + h0 = (time[0] + dh) * rad; + + Jset = getSetJ(h0, lw, phi, dec, n, M, L); + Jrise = Jnoon - (Jset - Jnoon); + + result[time[1]] = fromJulian(Jrise); + result[time[2]] = fromJulian(Jset); + } + + return result; +}; + + +// moon calculations, based on http://aa.quae.nl/en/reken/hemelpositie.html formulas + +function moonCoords(d) { // geocentric ecliptic coordinates of the moon + + var L = rad * (218.316 + 13.176396 * d), // ecliptic longitude + M = rad * (134.963 + 13.064993 * d), // mean anomaly + F = rad * (93.272 + 13.229350 * d), // mean distance + + l = L + rad * 6.289 * sin(M), // longitude + b = rad * 5.128 * sin(F), // latitude + dt = 385001 - 20905 * cos(M); // distance to the moon in km + + return { + ra: rightAscension(l, b), + dec: declination(l, b), + dist: dt + }; +} + +SunCalc.getMoonPosition = function (date, lat, lng) { + + var lw = rad * -lng, + phi = rad * lat, + d = toDays(date), + + c = moonCoords(d), + H = siderealTime(d, lw) - c.ra, + h = altitude(H, phi, c.dec), + // formula 14.1 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998. + pa = atan(sin(H), tan(phi) * cos(c.dec) - sin(c.dec) * cos(H)); + + h = h + astroRefraction(h); // altitude correction for refraction + + return { + azimuth: azimuth(H, phi, c.dec), + altitude: h, + distance: c.dist, + parallacticAngle: pa + }; +}; + + +// calculations for illumination parameters of the moon, +// based on http://idlastro.gsfc.nasa.gov/ftp/pro/astro/mphase.pro formulas and +// Chapter 48 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998. + +SunCalc.getMoonIllumination = function (date) { + + var d = toDays(date || new Date()), + s = sunCoords(d), + m = moonCoords(d), + + sdist = 149598000, // distance from Earth to Sun in km + + phi = acos(sin(s.dec) * sin(m.dec) + cos(s.dec) * cos(m.dec) * cos(s.ra - m.ra)), + inc = atan(sdist * sin(phi), m.dist - sdist * cos(phi)), + angle = atan(cos(s.dec) * sin(s.ra - m.ra), sin(s.dec) * cos(m.dec) - + cos(s.dec) * sin(m.dec) * cos(s.ra - m.ra)); + + return { + fraction: (1 + cos(inc)) / 2, + phase: 0.5 + 0.5 * inc * (angle < 0 ? -1 : 1) / Math.PI, + angle: angle + }; +}; + + +function hoursLater(date, h) { + return new Date(date.valueOf() + h * dayMs / 24); +} + +// calculations for moon rise/set times are based on http://www.stargazing.net/kepler/moonrise.html article + +SunCalc.getMoonTimes = function (date, lat, lng, inUTC) { + var t = new Date(date); + if (inUTC) t.setUTCHours(0, 0, 0, 0); + else t.setHours(0, 0, 0, 0); + + var hc = 0.133 * rad, + h0 = SunCalc.getMoonPosition(t, lat, lng).altitude - hc, + h1, h2, rise, set, a, b, xe, ye, d, roots, x1, x2, dx; + + // go in 2-hour chunks, each time seeing if a 3-point quadratic curve crosses zero (which means rise or set) + for (var i = 1; i <= 24; i += 2) { + h1 = SunCalc.getMoonPosition(hoursLater(t, i), lat, lng).altitude - hc; + h2 = SunCalc.getMoonPosition(hoursLater(t, i + 1), lat, lng).altitude - hc; + + a = (h0 + h2) / 2 - h1; + b = (h2 - h0) / 2; + xe = -b / (2 * a); + ye = (a * xe + b) * xe + h1; + d = b * b - 4 * a * h1; + roots = 0; + + if (d >= 0) { + dx = Math.sqrt(d) / (Math.abs(a) * 2); + x1 = xe - dx; + x2 = xe + dx; + if (Math.abs(x1) <= 1) roots++; + if (Math.abs(x2) <= 1) roots++; + if (x1 < -1) x1 = x2; + } + + if (roots === 1) { + if (h0 < 0) rise = i + x1; + else set = i + x1; + + } else if (roots === 2) { + rise = i + (ye < 0 ? x2 : x1); + set = i + (ye < 0 ? x1 : x2); + } + + if (rise && set) break; + + h0 = h2; + } + + var result = {}; + + if (rise) result.rise = hoursLater(t, rise); + if (set) result.set = hoursLater(t, set); + + if (!rise && !set) result[ye > 0 ? 'alwaysUp' : 'alwaysDown'] = true; + + return result; +}; + + +// export as Node module / AMD module / browser variable +if (typeof exports === 'object' && typeof module !== 'undefined') module.exports = SunCalc; +else if (typeof define === 'function' && define.amd) define(SunCalc); +else window.SunCalc = SunCalc; + +}());