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}