Commit pirate JS files
This commit is contained in:
153
.gitignore
vendored
Normal file
153
.gitignore
vendored
Normal file
@@ -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*
|
||||
39
js/Animation.js
Normal file
39
js/Animation.js
Normal file
@@ -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() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
60
js/Application.js
Normal file
60
js/Application.js
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
75
js/AsyncResourceLoader.js
Normal file
75
js/AsyncResourceLoader.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
103
js/CSSDynamic.js
Normal file
103
js/CSSDynamic.js
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
96
js/CandidateFilterBase.js
Normal file
96
js/CandidateFilterBase.js
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
87
js/CandidateFilterByBadTelemetry.js
Normal file
87
js/CandidateFilterByBadTelemetry.js
Normal file
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
553
js/CandidateFilterByFingerprinting.js
Normal file
553
js/CandidateFilterByFingerprinting.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
284
js/CandidateFilterBySpec.js
Normal file
284
js/CandidateFilterBySpec.js
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
62
js/CandidateFilterConfirmed.js
Normal file
62
js/CandidateFilterConfirmed.js
Normal file
@@ -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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
91
js/CandidateFilterHeartbeat.js
Normal file
91
js/CandidateFilterHeartbeat.js
Normal file
@@ -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}).`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
69
js/CandidateFilterHighResLocation.js
Normal file
69
js/CandidateFilterHighResLocation.js
Normal file
@@ -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.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1886
js/Chart.js
vendored
Normal file
1886
js/Chart.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
130
js/CodecExpandedBasicTelemetry.js
Normal file
130
js/CodecExpandedBasicTelemetry.js
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
95
js/CodecHeartbeat.js
Normal file
95
js/CodecHeartbeat.js
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
153
js/CodecHighResLocation.js
Normal file
153
js/CodecHighResLocation.js
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
664
js/DomWidgets.js
Normal file
664
js/DomWidgets.js
Normal file
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
64
js/GreatCircle.js
Normal file
64
js/GreatCircle.js
Normal file
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
};
|
||||
897
js/MsgDefinitionInputUiController.js
Normal file
897
js/MsgDefinitionInputUiController.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
2134
js/SpotMap.js
Normal file
2134
js/SpotMap.js
Normal file
File diff suppressed because it is too large
Load Diff
49
js/SpotMapAsyncLoader.js
Normal file
49
js/SpotMapAsyncLoader.js
Normal file
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
679
js/TabularData.js
Normal file
679
js/TabularData.js
Normal file
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
109
js/Timeline.js
Normal file
109
js/Timeline.js
Normal file
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
178
js/WSPR.js
Normal file
178
js/WSPR.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
447
js/WSPREncoded.js
Normal file
447
js/WSPREncoded.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1095
js/WsprCodec.js
Normal file
1095
js/WsprCodec.js
Normal file
File diff suppressed because it is too large
Load Diff
250
js/WsprMessageCandidate.js
Normal file
250
js/WsprMessageCandidate.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
1170
js/WsprSearchResultDataTableBuilder.js
Normal file
1170
js/WsprSearchResultDataTableBuilder.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
116
js/WsprSearchResultDataTableColumnBuilderHeartbeat.js
Normal file
116
js/WsprSearchResultDataTableColumnBuilderHeartbeat.js
Normal file
@@ -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?.());
|
||||
}
|
||||
}
|
||||
123
js/WsprSearchResultDataTableColumnBuilderHighResLocation.js
Normal file
123
js/WsprSearchResultDataTableColumnBuilderHighResLocation.js
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
58
js/WsprSearchResultDataTableColumnBuilderRegularType1.js
Normal file
58
js/WsprSearchResultDataTableColumnBuilderRegularType1.js
Normal file
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
70
js/WsprSearchResultDataTableColumnBuilderTelemetryBasic.js
Normal file
70
js/WsprSearchResultDataTableColumnBuilderTelemetryBasic.js
Normal file
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
170
js/WsprSearchUi.js
Normal file
170
js/WsprSearchUi.js
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
926
js/WsprSearchUiChartsController.js
Normal file
926
js/WsprSearchUiChartsController.js
Normal file
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
35
js/WsprSearchUiDataTableColumnOrder.js
Normal file
35
js/WsprSearchUiDataTableColumnOrder.js
Normal file
@@ -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());
|
||||
}
|
||||
}
|
||||
1882
js/WsprSearchUiDataTableController.js
Normal file
1882
js/WsprSearchUiDataTableController.js
Normal file
File diff suppressed because it is too large
Load Diff
790
js/WsprSearchUiDataTableRowProController.js
Normal file
790
js/WsprSearchUiDataTableRowProController.js
Normal file
@@ -0,0 +1,790 @@
|
||||
/*
|
||||
Copyright (c) 2023-forever Douglas Malnati. All rights reserved.
|
||||
|
||||
See the /faq/tos page for details.
|
||||
|
||||
(If this generated header is stamped on a file which is a 3rd party file or under a different license or copyright, then ignore this copyright statement and use that file's terms.)
|
||||
*/
|
||||
|
||||
import { CollapsableTitleBox, DialogBox } from './DomWidgets.js';
|
||||
import { WSPR } from '/js/WSPR.js';
|
||||
|
||||
export class WsprSearchUiDataTableRowProController
|
||||
{
|
||||
constructor(cfg)
|
||||
{
|
||||
this.cfg = cfg || {};
|
||||
this.data = this.cfg.data || {};
|
||||
|
||||
this.dialog = new DialogBox();
|
||||
this.dialog.SetTitleBar("Pro Row Insight");
|
||||
this.hasShown = false;
|
||||
this.colWidthList = ["20%", "12%", "6%", "37%", "25%"];
|
||||
|
||||
let content = this.dialog.GetContentContainer();
|
||||
content.style.width = "1110px";
|
||||
content.style.minWidth = "1110px";
|
||||
content.style.minHeight = "520px";
|
||||
content.style.maxHeight = "calc(100vh - 120px)";
|
||||
|
||||
this.body = document.createElement("div");
|
||||
this.body.style.padding = "8px";
|
||||
this.body.style.display = "flex";
|
||||
this.body.style.flexDirection = "column";
|
||||
this.body.style.gap = "8px";
|
||||
content.appendChild(this.body);
|
||||
|
||||
this.#RefreshBody();
|
||||
}
|
||||
|
||||
GetUI()
|
||||
{
|
||||
return this.dialog.GetUI();
|
||||
}
|
||||
|
||||
Show()
|
||||
{
|
||||
this.#RefreshBody();
|
||||
if (!this.hasShown)
|
||||
{
|
||||
let ui = this.dialog.GetUI();
|
||||
ui.style.left = "80px";
|
||||
ui.style.top = "80px";
|
||||
this.hasShown = true;
|
||||
}
|
||||
this.dialog.Show();
|
||||
}
|
||||
|
||||
#RefreshBody()
|
||||
{
|
||||
this.body.innerHTML = "";
|
||||
this.rxRecord__nodeSet = new Map();
|
||||
this.rxRecordHighlightNodeSet = new Set();
|
||||
|
||||
let dt = this.data.dt;
|
||||
let rowIdx = this.data.rowIdx;
|
||||
let dtUtc = "";
|
||||
let dtLocal = "";
|
||||
if (dt && rowIdx != undefined)
|
||||
{
|
||||
dtUtc = dt.Get(rowIdx, "DateTimeUtc") || "";
|
||||
dtLocal = dt.Get(rowIdx, "DateTimeLocal") || "";
|
||||
}
|
||||
this.dialog.SetTitleBar(`Pro Row Insight - DateTimeUtc: ${dtUtc} | DateTimeLocal: ${dtLocal}`);
|
||||
|
||||
let slotMsgListList = this.#GetSlotMsgListList();
|
||||
for (let slot = 0; slot < 5; ++slot)
|
||||
{
|
||||
this.body.appendChild(this.#MakeSlotSection(slot, slotMsgListList[slot] || []));
|
||||
}
|
||||
}
|
||||
|
||||
#GetSlotMsgListList()
|
||||
{
|
||||
let slotMsgListList = [[], [], [], [], []];
|
||||
|
||||
let dt = this.data.dt;
|
||||
let rowIdx = this.data.rowIdx;
|
||||
let wsprSearch = this.data.wsprSearch;
|
||||
let rowMeta = dt && rowIdx != undefined ? dt.GetRowMetaData(rowIdx) : null;
|
||||
let time = rowMeta?.time;
|
||||
|
||||
// Preferred source: full per-window slot message lists from WsprSearch.
|
||||
let windowData = wsprSearch?.time__windowData?.get?.(time);
|
||||
if (windowData?.slotDataList)
|
||||
{
|
||||
for (let slot = 0; slot < 5; ++slot)
|
||||
{
|
||||
slotMsgListList[slot] = windowData.slotDataList[slot]?.msgList || [];
|
||||
}
|
||||
return slotMsgListList;
|
||||
}
|
||||
|
||||
// Fallback source: selected single-candidate-per-slot snapshot.
|
||||
let slotMsgList = rowMeta?.slotMsgList || [];
|
||||
for (let slot = 0; slot < 5; ++slot)
|
||||
{
|
||||
let msg = slotMsgList[slot];
|
||||
if (msg)
|
||||
{
|
||||
slotMsgListList[slot] = [msg];
|
||||
}
|
||||
}
|
||||
|
||||
return slotMsgListList;
|
||||
}
|
||||
|
||||
#MakeSlotSection(slotIdx, msgList)
|
||||
{
|
||||
let section = new CollapsableTitleBox();
|
||||
section.SetTitle(`Slot ${slotIdx} (click to open/collapse)`);
|
||||
section.SetMinWidth("0px");
|
||||
section.Show();
|
||||
let sectionUi = section.GetUI();
|
||||
let sectionBody = section.GetContentContainer();
|
||||
|
||||
let table = document.createElement("table");
|
||||
table.style.borderCollapse = "collapse";
|
||||
table.style.backgroundColor = "white";
|
||||
table.style.width = "100%";
|
||||
table.style.tableLayout = "fixed";
|
||||
this.#AppendColGroup(table);
|
||||
|
||||
let thead = document.createElement("thead");
|
||||
let htr = document.createElement("tr");
|
||||
this.#AppendCell(htr, "Type", true);
|
||||
this.#AppendCell(htr, "Message", true);
|
||||
this.#AppendCell(htr, "Count", true);
|
||||
this.#AppendCell(htr, "Status", true);
|
||||
this.#AppendCell(htr, "Details", true);
|
||||
thead.appendChild(htr);
|
||||
table.appendChild(thead);
|
||||
|
||||
let tbody = document.createElement("tbody");
|
||||
let msgGroupList = this.#GetMsgGroupList(msgList);
|
||||
if (msgGroupList.length == 0)
|
||||
{
|
||||
let tr = document.createElement("tr");
|
||||
this.#AppendCell(tr, "-", false);
|
||||
this.#AppendCell(tr, "-", false);
|
||||
this.#AppendCell(tr, "0", false);
|
||||
this.#AppendCell(tr, "-", false);
|
||||
this.#SetRowBackground(tr, "#ffecec");
|
||||
let tdDetails = this.#MakeDetailsCell();
|
||||
this.#SetDetailsPlaceholder(tdDetails, "No messages in this slot.", false);
|
||||
tr.appendChild(tdDetails);
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
else
|
||||
{
|
||||
let candidateRowCount = 0;
|
||||
for (const g of msgGroupList)
|
||||
{
|
||||
candidateRowCount += g.isActive ? 1 : 0;
|
||||
}
|
||||
|
||||
let tdDetails = this.#MakeDetailsCell();
|
||||
tdDetails.rowSpan = msgGroupList.length;
|
||||
this.#SetDetailsPlaceholder(tdDetails, "Click row to show/hide RX details.", true);
|
||||
let activeRowIdx = -1;
|
||||
let activeTr = null;
|
||||
|
||||
for (let idx = 0; idx < msgGroupList.length; ++idx)
|
||||
{
|
||||
let g = msgGroupList[idx];
|
||||
let tr = document.createElement("tr");
|
||||
tr.style.cursor = "pointer";
|
||||
let suppressClickForSelection = false;
|
||||
|
||||
this.#AppendCell(tr, g.msgTypeDisplay, false);
|
||||
this.#AppendCell(tr, g.msgDisplay, false);
|
||||
this.#AppendCell(tr, g.rxCount.toString(), false);
|
||||
this.#AppendCell(tr, g.rejectReason, false);
|
||||
let isSingleCandidateRow = candidateRowCount == 1 && g.isActive;
|
||||
this.#SetRowBackground(tr, isSingleCandidateRow ? "#ecffec" : "#ffecec");
|
||||
|
||||
if (idx == 0)
|
||||
{
|
||||
tr.appendChild(tdDetails);
|
||||
}
|
||||
|
||||
tr.addEventListener("mousedown", (e) => {
|
||||
if (e.detail > 1)
|
||||
{
|
||||
e.preventDefault();
|
||||
window.getSelection?.()?.removeAllRanges?.();
|
||||
suppressClickForSelection = false;
|
||||
return;
|
||||
}
|
||||
|
||||
suppressClickForSelection = this.#SelectionIntersectsNode(tr);
|
||||
});
|
||||
|
||||
tr.addEventListener("dblclick", (e) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
tr.addEventListener("click", () => {
|
||||
if (suppressClickForSelection || this.#SelectionIntersectsNode(tr))
|
||||
{
|
||||
suppressClickForSelection = false;
|
||||
return;
|
||||
}
|
||||
|
||||
suppressClickForSelection = false;
|
||||
|
||||
if (activeTr)
|
||||
{
|
||||
this.#SetRowActiveStyle(activeTr, tdDetails, false);
|
||||
}
|
||||
|
||||
if (activeRowIdx == idx)
|
||||
{
|
||||
activeRowIdx = -1;
|
||||
activeTr = null;
|
||||
this.#SetDetailsPlaceholder(tdDetails, "Click row to show/hide RX details.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
activeRowIdx = idx;
|
||||
activeTr = tr;
|
||||
tdDetails.textContent = "";
|
||||
tdDetails.appendChild(this.#MakeRxDetailsUi(g.rxRecordList, slotIdx, g));
|
||||
this.#SetRowActiveStyle(activeTr, tdDetails, true);
|
||||
});
|
||||
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
table.appendChild(tbody);
|
||||
sectionBody.appendChild(table);
|
||||
|
||||
return sectionUi;
|
||||
}
|
||||
|
||||
#AppendCell(tr, text, isHeader)
|
||||
{
|
||||
let td = document.createElement(isHeader ? "th" : "td");
|
||||
td.textContent = text;
|
||||
td.style.border = "1px solid #999";
|
||||
td.style.padding = "3px 6px";
|
||||
td.style.verticalAlign = "top";
|
||||
td.style.whiteSpace = "normal";
|
||||
td.style.overflowWrap = "anywhere";
|
||||
if (isHeader)
|
||||
{
|
||||
td.style.backgroundColor = "#ddd";
|
||||
td.style.textAlign = "left";
|
||||
}
|
||||
tr.appendChild(td);
|
||||
}
|
||||
|
||||
#AppendColGroup(table)
|
||||
{
|
||||
// Keep a consistent width profile across every slot table.
|
||||
let cg = document.createElement("colgroup");
|
||||
for (const w of this.colWidthList)
|
||||
{
|
||||
let col = document.createElement("col");
|
||||
col.style.width = w;
|
||||
cg.appendChild(col);
|
||||
}
|
||||
table.appendChild(cg);
|
||||
}
|
||||
|
||||
#MakeDetailsCell()
|
||||
{
|
||||
let td = document.createElement("td");
|
||||
td.style.border = "1px solid #999";
|
||||
td.style.padding = "3px 6px";
|
||||
td.style.verticalAlign = "top";
|
||||
td.style.minWidth = "0";
|
||||
td.style.whiteSpace = "pre-wrap";
|
||||
td.style.fontFamily = "monospace";
|
||||
td.style.userSelect = "text";
|
||||
td.style.cursor = "text";
|
||||
|
||||
// Keep details text selectable/copyable without toggling row state.
|
||||
td.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
return td;
|
||||
}
|
||||
|
||||
#SelectionIntersectsNode(node)
|
||||
{
|
||||
let selection = window.getSelection?.();
|
||||
if (!selection || selection.rangeCount == 0 || selection.isCollapsed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let range = selection.getRangeAt(0);
|
||||
let commonAncestor = range.commonAncestorContainer;
|
||||
return node.contains(commonAncestor);
|
||||
}
|
||||
|
||||
#SetDetailsPlaceholder(td, text, useDim)
|
||||
{
|
||||
td.textContent = "";
|
||||
let span = document.createElement("span");
|
||||
span.textContent = text;
|
||||
if (useDim)
|
||||
{
|
||||
span.style.color = "#666";
|
||||
}
|
||||
td.appendChild(span);
|
||||
}
|
||||
|
||||
#SetRowBackground(tr, color)
|
||||
{
|
||||
for (const cell of tr.cells)
|
||||
{
|
||||
if (cell.tagName == "TD")
|
||||
{
|
||||
cell.style.backgroundColor = color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#SetRowActiveStyle(tr, tdDetails, active)
|
||||
{
|
||||
// Reset row/detail cell active effect only (do not mutate border geometry).
|
||||
for (const cell of tr.cells)
|
||||
{
|
||||
if (cell.tagName == "TD")
|
||||
{
|
||||
cell.style.boxShadow = "none";
|
||||
}
|
||||
}
|
||||
tdDetails.style.boxShadow = "none";
|
||||
|
||||
if (!active)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Active row: emphasize inward (inset) so layout does not expand outward.
|
||||
for (let i = 0; i < tr.cells.length; ++i)
|
||||
{
|
||||
let cell = tr.cells[i];
|
||||
if (cell.tagName != "TD")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let insetTopBottom = "inset 0 2px 0 #444, inset 0 -2px 0 #444";
|
||||
let insetLeft = i == 0 ? ", inset 2px 0 0 #444" : "";
|
||||
cell.style.boxShadow = insetTopBottom + insetLeft;
|
||||
}
|
||||
|
||||
// Keep details visually tied to active row with inward emphasis.
|
||||
tdDetails.style.boxShadow = "inset 0 2px 0 #444, inset 0 -2px 0 #444, inset 2px 0 0 #444, inset -2px 0 0 #444";
|
||||
}
|
||||
|
||||
#GetMsgGroupList(msgList)
|
||||
{
|
||||
let keyToGroup = new Map();
|
||||
|
||||
for (const msg of msgList)
|
||||
{
|
||||
if (!msg)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let key = this.#GetMsgKey(msg);
|
||||
if (!keyToGroup.has(key))
|
||||
{
|
||||
keyToGroup.set(key, {
|
||||
msgTypeDisplay: this.#GetMsgTypeDisplay(msg),
|
||||
msgDisplay: this.#GetMsgDisplay(msg),
|
||||
rejectReason: this.#GetRejectReason(msg),
|
||||
isCandidate: false,
|
||||
isConfirmed: false,
|
||||
isActive: false,
|
||||
msgList: [],
|
||||
rxRecordList: [],
|
||||
});
|
||||
}
|
||||
|
||||
let g = keyToGroup.get(key);
|
||||
g.isCandidate = g.isCandidate || (msg.IsCandidate && msg.IsCandidate());
|
||||
g.isConfirmed = g.isConfirmed || (msg.IsConfirmed && msg.IsConfirmed());
|
||||
g.isActive = g.isActive || (msg.IsNotRejected && msg.IsNotRejected());
|
||||
g.msgList.push(msg);
|
||||
if (Array.isArray(msg.rxRecordList))
|
||||
{
|
||||
g.rxRecordList.push(...msg.rxRecordList);
|
||||
}
|
||||
}
|
||||
|
||||
let out = Array.from(keyToGroup.values());
|
||||
for (const g of out)
|
||||
{
|
||||
g.rxCount = g.rxRecordList.length;
|
||||
}
|
||||
|
||||
out.sort((a, b) => {
|
||||
if (b.rxCount != a.rxCount)
|
||||
{
|
||||
return b.rxCount - a.rxCount;
|
||||
}
|
||||
return a.msgDisplay.localeCompare(b.msgDisplay);
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
#GetMsgKey(msg)
|
||||
{
|
||||
let f = msg.fields || {};
|
||||
let decodeType = msg.decodeDetails?.type || "";
|
||||
return JSON.stringify([
|
||||
msg.type || "",
|
||||
decodeType,
|
||||
f.callsign || "",
|
||||
f.grid4 || "",
|
||||
f.powerDbm || "",
|
||||
]);
|
||||
}
|
||||
|
||||
#GetMsgDisplay(msg)
|
||||
{
|
||||
let f = msg.fields || {};
|
||||
return `${f.callsign || ""} ${f.grid4 || ""} ${f.powerDbm || ""}`.trim();
|
||||
}
|
||||
|
||||
#GetMsgTypeDisplay(msg)
|
||||
{
|
||||
let type = msg.type || "";
|
||||
if (type == "telemetry")
|
||||
{
|
||||
let decodeType = msg.decodeDetails?.type || "?";
|
||||
if (decodeType == "extended")
|
||||
{
|
||||
let prettyType = msg.decodeDetails?.extended?.prettyType || "";
|
||||
if (prettyType != "")
|
||||
{
|
||||
return `telemetry/${prettyType}`;
|
||||
}
|
||||
}
|
||||
return `telemetry/${decodeType}`;
|
||||
}
|
||||
|
||||
if (type == "regular")
|
||||
{
|
||||
return "regular";
|
||||
}
|
||||
|
||||
return type || "-";
|
||||
}
|
||||
|
||||
#GetRejectReason(msg)
|
||||
{
|
||||
let reason = "";
|
||||
|
||||
if (msg.IsConfirmed && msg.IsConfirmed())
|
||||
{
|
||||
reason = "Confirmed";
|
||||
}
|
||||
else if (msg.IsCandidate && msg.IsCandidate())
|
||||
{
|
||||
reason = "Candidate";
|
||||
}
|
||||
else
|
||||
{
|
||||
let audit = msg.candidateFilterAuditList?.[0];
|
||||
if (!audit)
|
||||
{
|
||||
reason = "Rejected";
|
||||
}
|
||||
else if (audit.note)
|
||||
{
|
||||
reason = `${audit.type || "Rejected"}: ${audit.note}`;
|
||||
}
|
||||
else
|
||||
{
|
||||
reason = audit.type || "Rejected";
|
||||
}
|
||||
}
|
||||
|
||||
if (this.#IsFingerprintReferenceMsg(msg))
|
||||
{
|
||||
reason += reason != "" ? " (frequency reference)" : "Frequency reference";
|
||||
}
|
||||
|
||||
return reason;
|
||||
}
|
||||
|
||||
#IsFingerprintReferenceMsg(msg)
|
||||
{
|
||||
let referenceMsg = this.data.wsprSearch
|
||||
?.time__windowData
|
||||
?.get?.(this.data.dt?.GetRowMetaData(this.data.rowIdx)?.time)
|
||||
?.fingerprintingData
|
||||
?.referenceAudit
|
||||
?.referenceMsg;
|
||||
|
||||
return referenceMsg === msg;
|
||||
}
|
||||
|
||||
#MakeRxDetailsUi(rxRecordList, slotIdx, msgGroup)
|
||||
{
|
||||
let expectedFreqHz = this.#GetExpectedFreqHz();
|
||||
let rowList = [];
|
||||
for (const rx of rxRecordList)
|
||||
{
|
||||
let freq = "";
|
||||
let freqSort = Number.NaN;
|
||||
let offset = null;
|
||||
if (rx?.frequency != undefined && rx?.frequency !== "")
|
||||
{
|
||||
let n = Number(rx.frequency);
|
||||
if (Number.isFinite(n))
|
||||
{
|
||||
freqSort = n;
|
||||
freq = n.toLocaleString("en-US");
|
||||
if (expectedFreqHz != null)
|
||||
{
|
||||
offset = Math.round(n - expectedFreqHz);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
freq = `${rx.frequency}`;
|
||||
}
|
||||
}
|
||||
|
||||
rowList.push({
|
||||
rxRecord: rx,
|
||||
station: rx?.rxCallsign ? rx.rxCallsign : "",
|
||||
freq: freq,
|
||||
freqSort: freqSort,
|
||||
offset: offset,
|
||||
});
|
||||
}
|
||||
|
||||
rowList.sort((a, b) => {
|
||||
let aOffsetValid = a.offset != null;
|
||||
let bOffsetValid = b.offset != null;
|
||||
if (aOffsetValid && bOffsetValid && a.offset != b.offset)
|
||||
{
|
||||
return b.offset - a.offset;
|
||||
}
|
||||
if (aOffsetValid != bOffsetValid)
|
||||
{
|
||||
return aOffsetValid ? -1 : 1;
|
||||
}
|
||||
|
||||
let c1 = a.station.localeCompare(b.station);
|
||||
if (c1 != 0) { return c1; }
|
||||
|
||||
let aFreqValid = Number.isFinite(a.freqSort);
|
||||
let bFreqValid = Number.isFinite(b.freqSort);
|
||||
if (aFreqValid && bFreqValid && a.freqSort != b.freqSort)
|
||||
{
|
||||
return a.freqSort - b.freqSort;
|
||||
}
|
||||
|
||||
return a.freq.localeCompare(b.freq);
|
||||
});
|
||||
|
||||
let hdr = {
|
||||
station: "RX Station",
|
||||
freq: "RX Freq",
|
||||
offset: "+/-LaneHz",
|
||||
};
|
||||
|
||||
let wStation = hdr.station.length;
|
||||
let wFreq = hdr.freq.length;
|
||||
let wOffsetValue = 3;
|
||||
for (const r of rowList)
|
||||
{
|
||||
wStation = Math.max(wStation, r.station.length);
|
||||
wFreq = Math.max(wFreq, r.freq.length);
|
||||
if (r.offset != null)
|
||||
{
|
||||
wOffsetValue = Math.max(wOffsetValue, Math.abs(r.offset).toString().length);
|
||||
}
|
||||
}
|
||||
let wOffset = Math.max(hdr.offset.length, 1 + wOffsetValue);
|
||||
|
||||
let wrapper = document.createElement("div");
|
||||
wrapper.style.whiteSpace = "pre";
|
||||
wrapper.style.userSelect = "text";
|
||||
wrapper.style.fontFamily = "monospace";
|
||||
wrapper.style.fontSize = "12px";
|
||||
|
||||
let makeLine = (station, freq, offset) =>
|
||||
`${station.padEnd(wStation)} ${freq.padStart(wFreq)} ${offset.padStart(wOffset)}`;
|
||||
|
||||
let headerLine = this.#MakeDetailsTextRow(makeLine(hdr.station, hdr.freq, hdr.offset), false);
|
||||
wrapper.appendChild(headerLine);
|
||||
wrapper.appendChild(this.#MakeDetailsTextRow(makeLine("-".repeat(wStation), "-".repeat(wFreq), "-".repeat(wOffset)), false));
|
||||
|
||||
if (rowList.length == 0)
|
||||
{
|
||||
wrapper.appendChild(this.#MakeDetailsTextRow("(no receiving stations)", false));
|
||||
}
|
||||
else
|
||||
{
|
||||
for (const r of rowList)
|
||||
{
|
||||
let line = makeLine(r.station, r.freq, this.#FormatLaneOffset(r.offset, wOffsetValue));
|
||||
let rowNode = this.#MakeDetailsTextRow(line, true);
|
||||
|
||||
this.#RegisterRxRecordNode(r.rxRecord, rowNode);
|
||||
rowNode.addEventListener("mouseenter", () => {
|
||||
this.#ApplyFingerprintHover(slotIdx, msgGroup, r.rxRecord);
|
||||
});
|
||||
rowNode.addEventListener("mouseleave", () => {
|
||||
this.#ClearFingerprintHover();
|
||||
});
|
||||
|
||||
wrapper.appendChild(rowNode);
|
||||
}
|
||||
}
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
#GetExpectedFreqHz()
|
||||
{
|
||||
let band = this.data.band || "";
|
||||
let channel = this.data.channel;
|
||||
if (band == "" || channel == "" || channel == undefined)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
let channelDetails = WSPR.GetChannelDetails(band, channel);
|
||||
if (!channelDetails || !Number.isFinite(channelDetails.freq))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return channelDetails.freq;
|
||||
}
|
||||
|
||||
#FormatLaneOffset(offset, width)
|
||||
{
|
||||
if (offset == null)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
let sign = " ";
|
||||
if (offset > 0)
|
||||
{
|
||||
sign = "+";
|
||||
}
|
||||
else if (offset < 0)
|
||||
{
|
||||
sign = "-";
|
||||
}
|
||||
|
||||
return `${sign}${Math.abs(offset).toString().padStart(width)}`;
|
||||
}
|
||||
|
||||
#MakeDetailsTextRow(text, interactive)
|
||||
{
|
||||
let row = document.createElement("div");
|
||||
row.textContent = text;
|
||||
row.style.whiteSpace = "pre";
|
||||
row.style.cursor = interactive ? "default" : "text";
|
||||
return row;
|
||||
}
|
||||
|
||||
#RegisterRxRecordNode(rxRecord, node)
|
||||
{
|
||||
if (!this.rxRecord__nodeSet.has(rxRecord))
|
||||
{
|
||||
this.rxRecord__nodeSet.set(rxRecord, new Set());
|
||||
}
|
||||
|
||||
this.rxRecord__nodeSet.get(rxRecord).add(node);
|
||||
}
|
||||
|
||||
#ApplyFingerprintHover(slotIdx, msgGroup, rxRecord)
|
||||
{
|
||||
this.#ClearFingerprintHover();
|
||||
|
||||
let rxRecordSet = this.#GetFingerprintHoverRxRecordSet(slotIdx, msgGroup, rxRecord);
|
||||
if (rxRecordSet.size == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (const rxRecordHighlighted of rxRecordSet)
|
||||
{
|
||||
let nodeSet = this.rxRecord__nodeSet.get(rxRecordHighlighted);
|
||||
if (!nodeSet)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const node of nodeSet)
|
||||
{
|
||||
node.style.backgroundColor = "#eefbe7";
|
||||
this.rxRecordHighlightNodeSet.add(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#ClearFingerprintHover()
|
||||
{
|
||||
for (const node of this.rxRecordHighlightNodeSet)
|
||||
{
|
||||
node.style.backgroundColor = "";
|
||||
}
|
||||
this.rxRecordHighlightNodeSet.clear();
|
||||
}
|
||||
|
||||
#GetFingerprintHoverRxRecordSet(slotIdx, msgGroup, rxRecord)
|
||||
{
|
||||
let rxRecordSet = new Set();
|
||||
let referenceAudit = this.data.wsprSearch
|
||||
?.time__windowData
|
||||
?.get?.(this.data.dt?.GetRowMetaData(this.data.rowIdx)?.time)
|
||||
?.fingerprintingData
|
||||
?.referenceAudit;
|
||||
|
||||
if (!referenceAudit)
|
||||
{
|
||||
return rxRecordSet;
|
||||
}
|
||||
|
||||
if (slotIdx == referenceAudit.referenceSlot)
|
||||
{
|
||||
for (const slotAudit of referenceAudit.slotAuditList)
|
||||
{
|
||||
if (!slotAudit || slotAudit.slot == referenceAudit.referenceSlot)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
this.#AddFingerprintMatchesForRecord(rxRecordSet, slotAudit.msgAuditList, rxRecord, "rxRecordListA");
|
||||
}
|
||||
|
||||
return rxRecordSet;
|
||||
}
|
||||
|
||||
let slotAudit = referenceAudit.slotAuditList[slotIdx];
|
||||
if (!slotAudit)
|
||||
{
|
||||
return rxRecordSet;
|
||||
}
|
||||
|
||||
let msgSet = new Set(msgGroup.msgList || []);
|
||||
let msgAuditList = (slotAudit.msgAuditList || []).filter(msgAudit => msgSet.has(msgAudit.msg));
|
||||
this.#AddFingerprintMatchesForRecord(rxRecordSet, msgAuditList, rxRecord, "rxRecordListB");
|
||||
|
||||
return rxRecordSet;
|
||||
}
|
||||
|
||||
#AddFingerprintMatchesForRecord(rxRecordSet, msgAuditList, rxRecord, primaryListName)
|
||||
{
|
||||
for (const msgAudit of msgAuditList || [])
|
||||
{
|
||||
for (const rxCallMatch of msgAudit.rxCallMatchList || [])
|
||||
{
|
||||
let primaryList = rxCallMatch[primaryListName] || [];
|
||||
if (!primaryList.includes(rxRecord))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const rxRecordA of rxCallMatch.rxRecordListA || [])
|
||||
{
|
||||
rxRecordSet.add(rxRecordA);
|
||||
}
|
||||
for (const rxRecordB of rxCallMatch.rxRecordListB || [])
|
||||
{
|
||||
rxRecordSet.add(rxRecordB);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
239
js/WsprSearchUiDataTableVisibility.js
Normal file
239
js/WsprSearchUiDataTableVisibility.js
Normal file
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
Copyright (c) 2023-forever Douglas Malnati. All rights reserved.
|
||||
|
||||
See the /faq/tos page for details.
|
||||
|
||||
(If this generated header is stamped on a file which is a 3rd party file or under a different license or copyright, then ignore this copyright statement and use that file's terms.)
|
||||
*/
|
||||
|
||||
export class WsprSearchUiDataTableVisibility
|
||||
{
|
||||
static GetStoredToggle(storageKey, defaultValue)
|
||||
{
|
||||
let storedVal = localStorage.getItem(storageKey);
|
||||
if (storedVal == null)
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return storedVal == "yes";
|
||||
}
|
||||
|
||||
static GetDateTimeSpecList()
|
||||
{
|
||||
return [
|
||||
{ col: "DateTimeUtc", checked: true },
|
||||
{ col: "DateTimeLocal", checked: true },
|
||||
];
|
||||
}
|
||||
|
||||
static GetCheckboxSpecListMap()
|
||||
{
|
||||
return new Map([
|
||||
["basicTelemetryVisible", [
|
||||
{ col: "BtGpsValid", checked: true },
|
||||
{ col: "BtGrid56", checked: false },
|
||||
{ col: "BtGrid6", checked: false },
|
||||
{ col: "BtLat", checked: false },
|
||||
{ col: "BtLng", checked: false },
|
||||
{ col: "BtTempC", checked: false },
|
||||
{ col: "BtTempF", checked: false },
|
||||
{ col: "BtVoltage", checked: false },
|
||||
{ col: "BtAltM", checked: false },
|
||||
{ col: "BtAltFt", checked: false },
|
||||
{ col: "BtKPH", checked: false },
|
||||
{ col: "BtMPH", checked: false },
|
||||
]],
|
||||
["expandedBasicTelemetryVisible", [
|
||||
{ col: "EbtGpsValid", checked: true },
|
||||
{ col: "EbtVoltage", checked: true },
|
||||
{ col: "EbtLat", checked: false },
|
||||
{ col: "EbtLng", checked: false },
|
||||
{ col: "EbtLatitudeIdx", checked: false },
|
||||
{ col: "EbtLongitudeIdx", checked: false },
|
||||
{ col: "EbtTempF", checked: false },
|
||||
{ col: "EbtAltFt", checked: false },
|
||||
{ col: "EbtTempC", checked: false },
|
||||
{ col: "EbtAltM", checked: false },
|
||||
]],
|
||||
["highResLocationVisible", [
|
||||
{ col: "HiResLat", checked: true },
|
||||
{ col: "HiResLng", checked: true },
|
||||
{ col: "HiResReference", checked: false },
|
||||
{ col: "HiResLatitudeIdx", checked: false },
|
||||
{ col: "HiResLongitudeIdx", checked: false },
|
||||
]],
|
||||
["resolvedVisible", [
|
||||
{ col: "Lat", checked: true },
|
||||
{ col: "Lng", checked: true },
|
||||
{ col: "TempF", checked: true },
|
||||
{ col: "TempC", checked: false },
|
||||
{ col: "Voltage", checked: true },
|
||||
{ col: "AltFt", checked: true },
|
||||
{ col: "AltM", checked: false },
|
||||
{ col: "KPH", checked: false },
|
||||
{ col: "MPH", checked: true },
|
||||
{ col: "AltChgFpm", checked: true },
|
||||
{ col: "GpsMPH", checked: true },
|
||||
{ col: "DistMi", checked: true },
|
||||
{ col: "AltChgMpm", checked: false },
|
||||
{ col: "GpsKPH", checked: false },
|
||||
{ col: "DistKm", checked: false },
|
||||
{ col: "SolAngle", checked: true },
|
||||
{ col: "RxStationCount", checked: true },
|
||||
{ col: "WinFreqDrift", checked: true },
|
||||
]],
|
||||
["regularType1Visible", [
|
||||
{ col: "RegCall", checked: true },
|
||||
{ col: "RegGrid", checked: false },
|
||||
{ col: "RegPower", checked: true },
|
||||
{ col: "RegLat", checked: false },
|
||||
{ col: "RegLng", checked: false },
|
||||
]],
|
||||
["heartbeatVisible", [
|
||||
{ col: "UptimeMinutes", checked: true },
|
||||
{ col: "GpsLockType", checked: true },
|
||||
{ col: "GpsTryLockSeconds", checked: true },
|
||||
{ col: "GpsSatsInViewCount", checked: true },
|
||||
{ col: "TxFreqHzIdx", checked: false },
|
||||
{ col: "TxFreqMhz", checked: false },
|
||||
]],
|
||||
]);
|
||||
}
|
||||
|
||||
static GetCheckboxSpecList(storageKey)
|
||||
{
|
||||
return [... (this.GetCheckboxSpecListMap().get(storageKey) ?? [])];
|
||||
}
|
||||
|
||||
static GetVisibleColumnsForStorageKey(storageKey)
|
||||
{
|
||||
let specList = [];
|
||||
|
||||
if (storageKey == "dateTimeVisible")
|
||||
{
|
||||
specList = this.GetDateTimeSpecList();
|
||||
}
|
||||
else
|
||||
{
|
||||
specList = this.GetCheckboxSpecList(storageKey);
|
||||
}
|
||||
|
||||
let visibleColSet = new Set();
|
||||
let visibilityMap = this.#GetStoredCheckboxMap(`checkbox.${storageKey}`, specList);
|
||||
|
||||
for (let spec of specList)
|
||||
{
|
||||
if (visibilityMap.get(spec.col))
|
||||
{
|
||||
visibleColSet.add(spec.col);
|
||||
}
|
||||
}
|
||||
|
||||
if (storageKey == "dateTimeVisible" && localStorage.getItem("checkbox.dateTimeVisible") == null)
|
||||
{
|
||||
let dateTimeVisible = localStorage.getItem("dateTimeVisible") ?? "both";
|
||||
if (dateTimeVisible == "utc")
|
||||
{
|
||||
visibleColSet.delete("DateTimeLocal");
|
||||
}
|
||||
else if (dateTimeVisible == "local")
|
||||
{
|
||||
visibleColSet.delete("DateTimeUtc");
|
||||
}
|
||||
}
|
||||
|
||||
return visibleColSet;
|
||||
}
|
||||
|
||||
static GetVisibleColumnSet(allColList)
|
||||
{
|
||||
let visibleColSet = new Set(allColList);
|
||||
|
||||
let hideFromSpec = (storageKey, defaultSpecList) => {
|
||||
let visibilityMap = this.#GetStoredCheckboxMap(storageKey, defaultSpecList);
|
||||
for (let spec of defaultSpecList)
|
||||
{
|
||||
if (!visibilityMap.get(spec.col))
|
||||
{
|
||||
visibleColSet.delete(spec.col);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (let [storageKey, specList] of this.GetCheckboxSpecListMap())
|
||||
{
|
||||
hideFromSpec(`checkbox.${storageKey}`, specList);
|
||||
}
|
||||
|
||||
hideFromSpec(`checkbox.dateTimeVisible`, this.GetDateTimeSpecList());
|
||||
|
||||
// Backward compatibility with the prior radio-button DateTime setting.
|
||||
// Only apply this if the new checkbox state has never been stored.
|
||||
if (localStorage.getItem("checkbox.dateTimeVisible") == null)
|
||||
{
|
||||
let dateTimeVisible = localStorage.getItem("dateTimeVisible") ?? "both";
|
||||
if (dateTimeVisible == "utc")
|
||||
{
|
||||
visibleColSet.delete("DateTimeLocal");
|
||||
}
|
||||
else if (dateTimeVisible == "local")
|
||||
{
|
||||
visibleColSet.delete("DateTimeUtc");
|
||||
}
|
||||
}
|
||||
|
||||
let udDecodedVisible = this.GetStoredToggle("udDecodedVisible", true);
|
||||
let udRawVisible = this.GetStoredToggle("udRawVisible", false);
|
||||
let vdDecodedVisible = this.GetStoredToggle("vdDecodedVisible", true);
|
||||
let vdRawVisible = this.GetStoredToggle("vdRawVisible", false);
|
||||
|
||||
for (let col of allColList)
|
||||
{
|
||||
if (col.startsWith("slot") && col.includes(".ud."))
|
||||
{
|
||||
let isRaw = col.endsWith(".EncMsg");
|
||||
if ((isRaw && !udRawVisible) || (!isRaw && !udDecodedVisible))
|
||||
{
|
||||
visibleColSet.delete(col);
|
||||
}
|
||||
}
|
||||
|
||||
if (col.startsWith("slot") && col.includes(".vd."))
|
||||
{
|
||||
let isRaw = col.endsWith(".EncMsg");
|
||||
if ((isRaw && !vdRawVisible) || (!isRaw && !vdDecodedVisible))
|
||||
{
|
||||
visibleColSet.delete(col);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return visibleColSet;
|
||||
}
|
||||
|
||||
static #GetStoredCheckboxMap(storageKey, defaultSpecList)
|
||||
{
|
||||
let storedMap = new Map(defaultSpecList.map(spec => [spec.col, !!spec.checked]));
|
||||
let storedVal = localStorage.getItem(storageKey);
|
||||
|
||||
if (storedVal == null)
|
||||
{
|
||||
return storedMap;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
let obj = JSON.parse(storedVal);
|
||||
for (let [col, checked] of Object.entries(obj))
|
||||
{
|
||||
storedMap.set(col, !!checked);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return storedMap;
|
||||
}
|
||||
}
|
||||
181
js/WsprSearchUiFlightStatsController.js
Normal file
181
js/WsprSearchUiFlightStatsController.js
Normal file
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
Copyright (c) 2023-forever Douglas Malnati. All rights reserved.
|
||||
|
||||
See the /faq/tos page for details.
|
||||
|
||||
(If this generated header is stamped on a file which is a 3rd party file or under a different license or copyright, then ignore this copyright statement and use that file's terms.)
|
||||
*/
|
||||
|
||||
import * as utl from '/js/Utl.js';
|
||||
|
||||
import { Base } from './Base.js';
|
||||
import { TabularData } from '../../../../js/TabularData.js';
|
||||
|
||||
|
||||
|
||||
export class WsprSearchUiFlightStatsController
|
||||
extends Base
|
||||
{
|
||||
constructor(cfg)
|
||||
{
|
||||
super();
|
||||
|
||||
this.cfg = cfg;
|
||||
|
||||
this.ok = this.cfg.container;
|
||||
|
||||
if (this.ok)
|
||||
{
|
||||
this.ui = this.#MakeUI();
|
||||
this.cfg.container.appendChild(this.ui);
|
||||
}
|
||||
}
|
||||
|
||||
SetDebug(tf)
|
||||
{
|
||||
super.SetDebug(tf);
|
||||
|
||||
this.t.SetCcGlobal(tf);
|
||||
}
|
||||
|
||||
OnEvent(evt)
|
||||
{
|
||||
if (this.ok)
|
||||
{
|
||||
switch (evt.type) {
|
||||
case "DATA_TABLE_RAW_READY": this.#OnDataTableRawReady(evt); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#OnDataTableRawReady(evt)
|
||||
{
|
||||
this.t.Reset();
|
||||
this.t.Event(`WsprSearchUiFlightStatsController::OnDataTableRawReady Start`);
|
||||
|
||||
// clear existing child nodes
|
||||
this.cfg.container.innerHTML = "";
|
||||
|
||||
// get handle to data
|
||||
let td = evt.tabularDataReadOnly;
|
||||
|
||||
// calculate distance stats
|
||||
let distKm = 0;
|
||||
let distMi = 0;
|
||||
|
||||
td.ForEach((row) => {
|
||||
distKm += td.Get(row, "DistKm");
|
||||
distMi += td.Get(row, "DistMi");
|
||||
});
|
||||
|
||||
// calculate spot stats
|
||||
let spotCount = td.GetDataTable().length - 1;
|
||||
|
||||
// calculate duration stats
|
||||
let durationStr = "";
|
||||
if (td.Length() > 1)
|
||||
{
|
||||
let dtFirst = td.Get(td.Length() - 1, "DateTimeLocal");
|
||||
let dtLast = td.Get(0, "DateTimeLocal");
|
||||
|
||||
let msFirst = utl.ParseTimeToMs(dtFirst);
|
||||
let msLast = utl.ParseTimeToMs(dtLast);
|
||||
|
||||
let msDiff = msLast - msFirst;
|
||||
durationStr = utl.MsToDurationStrDaysHoursMinutes(msDiff);
|
||||
}
|
||||
|
||||
// calculate eastward laps around world using resolved location
|
||||
let lapCount = this.#CalculateEastwardLapCount(td);
|
||||
|
||||
// create summary
|
||||
let status =
|
||||
`
|
||||
Flight duration: ${durationStr}
|
||||
<br/>
|
||||
<br/>
|
||||
Laps around world: ${utl.Commas(lapCount)}
|
||||
<br/>
|
||||
<br/>
|
||||
Distance Traveled Km: ${utl.Commas(Math.round(distKm))}
|
||||
<br/>
|
||||
Distance Traveled Mi: ${utl.Commas(Math.round(distMi))}
|
||||
<br/>
|
||||
<br/>
|
||||
Spots: ${utl.Commas(spotCount)}
|
||||
`;
|
||||
|
||||
// update UI
|
||||
this.ui.innerHTML = status;
|
||||
|
||||
// replace with new
|
||||
this.cfg.container.appendChild(this.ui);
|
||||
|
||||
this.t.Event(`WsprSearchUiFlightStatsController::OnDataTableRawReady End`);
|
||||
}
|
||||
|
||||
#MakeUI()
|
||||
{
|
||||
this.ui = document.createElement('div');
|
||||
|
||||
return this.ui;
|
||||
}
|
||||
|
||||
#CalculateEastwardLapCount(td)
|
||||
{
|
||||
let lonList = [];
|
||||
|
||||
// Table is newest-first, so iterate oldest -> newest.
|
||||
for (let idx = td.Length() - 1; idx >= 0; --idx)
|
||||
{
|
||||
let lon = td.Get(idx, "Lng");
|
||||
if (lon == undefined || lon == null || lon === "")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
lon = Number(lon);
|
||||
if (Number.isFinite(lon))
|
||||
{
|
||||
lonList.push(lon);
|
||||
}
|
||||
}
|
||||
|
||||
if (lonList.length < 2)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Unwrap longitude so east/west movement is continuous across +/-180.
|
||||
let unwrappedLonList = [lonList[0]];
|
||||
for (let i = 1; i < lonList.length; ++i)
|
||||
{
|
||||
let prevRaw = lonList[i - 1];
|
||||
let curRaw = lonList[i];
|
||||
|
||||
let delta = curRaw - prevRaw;
|
||||
if (delta > 180) { delta -= 360; }
|
||||
if (delta < -180) { delta += 360; }
|
||||
|
||||
let nextUnwrapped = unwrappedLonList[unwrappedLonList.length - 1] + delta;
|
||||
unwrappedLonList.push(nextUnwrapped);
|
||||
}
|
||||
|
||||
let startLon = unwrappedLonList[0];
|
||||
let lapCount = 0;
|
||||
|
||||
// Count only eastward full wraps past start + 360n.
|
||||
for (let i = 1; i < unwrappedLonList.length; ++i)
|
||||
{
|
||||
let prevRel = unwrappedLonList[i - 1] - startLon;
|
||||
let curRel = unwrappedLonList[i] - startLon;
|
||||
|
||||
while (prevRel < (lapCount + 1) * 360 && curRel >= (lapCount + 1) * 360)
|
||||
{
|
||||
++lapCount;
|
||||
}
|
||||
}
|
||||
|
||||
return lapCount;
|
||||
}
|
||||
}
|
||||
1376
js/WsprSearchUiInputController.js
Normal file
1376
js/WsprSearchUiInputController.js
Normal file
File diff suppressed because it is too large
Load Diff
259
js/WsprSearchUiMapController.js
Normal file
259
js/WsprSearchUiMapController.js
Normal file
@@ -0,0 +1,259 @@
|
||||
/*
|
||||
Copyright (c) 2023-forever Douglas Malnati. All rights reserved.
|
||||
|
||||
See the /faq/tos page for details.
|
||||
|
||||
(If this generated header is stamped on a file which is a 3rd party file or under a different license or copyright, then ignore this copyright statement and use that file's terms.)
|
||||
*/
|
||||
|
||||
import * as utl from '/js/Utl.js';
|
||||
|
||||
import { Base } from './Base.js';
|
||||
import { SpotMapAsyncLoader } from './SpotMapAsyncLoader.js';
|
||||
import { TabularData } from '../../../../js/TabularData.js';
|
||||
import { WSPREncoded } from '/js/WSPREncoded.js';
|
||||
import { WsprSearchUiDataTableVisibility } from './WsprSearchUiDataTableVisibility.js';
|
||||
import { WsprSearchUiDataTableColumnOrder } from './WsprSearchUiDataTableColumnOrder.js';
|
||||
|
||||
|
||||
export class WsprSearchUiMapController
|
||||
extends Base
|
||||
{
|
||||
constructor(cfg)
|
||||
{
|
||||
super();
|
||||
|
||||
this.cfg = cfg;
|
||||
this.td = null;
|
||||
this.mapDataToken = 0;
|
||||
this.showEmptyMapRequested = false;
|
||||
this.showControlHelpRequested = false;
|
||||
|
||||
this.ok = this.cfg.container;
|
||||
|
||||
// map gets async loaded
|
||||
this.mapModule = null;
|
||||
this.map = null;
|
||||
SpotMapAsyncLoader.SetOnLoadCallback((module) => {
|
||||
this.mapModule = module;
|
||||
|
||||
this.map = new this.mapModule.SpotMap({
|
||||
container: this.ui,
|
||||
});
|
||||
|
||||
this.map.SetDebug(this.debug);
|
||||
|
||||
if (this.showEmptyMapRequested)
|
||||
{
|
||||
this.map.SetSpotList([]);
|
||||
this.showEmptyMapRequested = false;
|
||||
}
|
||||
else if (this.td)
|
||||
{
|
||||
this.ScheduleMapData();
|
||||
}
|
||||
|
||||
if (this.showControlHelpRequested)
|
||||
{
|
||||
this.map.ShowControlHelpDialog();
|
||||
this.showControlHelpRequested = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (this.ok)
|
||||
{
|
||||
this.ui = this.MakeUI();
|
||||
this.cfg.container.appendChild(this.ui);
|
||||
}
|
||||
}
|
||||
|
||||
SetDebug(tf)
|
||||
{
|
||||
super.SetDebug(tf);
|
||||
|
||||
this.t.SetCcGlobal(tf);
|
||||
}
|
||||
|
||||
OnEvent(evt)
|
||||
{
|
||||
if (this.ok)
|
||||
{
|
||||
switch (evt.type) {
|
||||
case "DATA_TABLE_RAW_READY": this.OnDataTableRawReady(evt); break;
|
||||
case "DATA_TABLE_VISIBILITY_CHANGED": this.OnDataTableVisibilityChanged(evt); break;
|
||||
case "SHOW_EMPTY_MAP": this.OnShowEmptyMap(evt); break;
|
||||
case "SHOW_MAP_CONTROL_HELP": this.OnShowMapControlHelp(evt); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OnDataTableRawReady(evt)
|
||||
{
|
||||
// cache data
|
||||
this.td = evt.tabularDataReadOnly;
|
||||
|
||||
// check if we can map immediately
|
||||
if (this.mapModule != null)
|
||||
{
|
||||
this.ScheduleMapData();
|
||||
}
|
||||
}
|
||||
|
||||
OnDataTableVisibilityChanged(evt)
|
||||
{
|
||||
if (this.td && this.mapModule != null)
|
||||
{
|
||||
this.ScheduleMapData();
|
||||
}
|
||||
}
|
||||
|
||||
OnShowEmptyMap(evt)
|
||||
{
|
||||
this.td = null;
|
||||
|
||||
if (this.map)
|
||||
{
|
||||
this.map.SetSpotList([]);
|
||||
}
|
||||
else
|
||||
{
|
||||
this.showEmptyMapRequested = true;
|
||||
}
|
||||
}
|
||||
|
||||
OnShowMapControlHelp(evt)
|
||||
{
|
||||
if (this.map)
|
||||
{
|
||||
this.map.ShowControlHelpDialog();
|
||||
}
|
||||
else
|
||||
{
|
||||
this.showControlHelpRequested = true;
|
||||
}
|
||||
}
|
||||
|
||||
ScheduleMapData()
|
||||
{
|
||||
let token = ++this.mapDataToken;
|
||||
|
||||
let run = () => {
|
||||
if (token != this.mapDataToken)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this.MapData();
|
||||
};
|
||||
|
||||
if (window.requestIdleCallback)
|
||||
{
|
||||
window.requestIdleCallback(() => {
|
||||
window.requestAnimationFrame(run);
|
||||
}, { timeout: 250 });
|
||||
}
|
||||
else
|
||||
{
|
||||
window.setTimeout(() => {
|
||||
window.requestAnimationFrame(run);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
MapData()
|
||||
{
|
||||
this.t.Reset();
|
||||
this.t.Event(`WsprSearchUiMapController::MapData Start`);
|
||||
|
||||
let spotList = [];
|
||||
if (this.td.Idx("Lat") != undefined && this.td.Idx("Lng") != undefined)
|
||||
{
|
||||
this.td.ForEach(row => {
|
||||
let metaData = this.td.GetRowMetaData(row);
|
||||
let locationSource = metaData?.overlap?.resolved?.sourceByFamily?.location ?? null;
|
||||
let latResolved = this.td.Idx("Lat") != undefined ? this.td.Get(row, "Lat") : null;
|
||||
let lngResolved = this.td.Idx("Lng") != undefined ? this.td.Get(row, "Lng") : null;
|
||||
|
||||
let lat = null;
|
||||
let lng = null;
|
||||
|
||||
if (latResolved != null && lngResolved != null)
|
||||
{
|
||||
lat = latResolved;
|
||||
lng = lngResolved;
|
||||
}
|
||||
|
||||
if (lat != null && lng != null)
|
||||
{
|
||||
// get a list of all the reporting stations
|
||||
let seenDataList = [];
|
||||
for (let msg of metaData.slotMsgList)
|
||||
{
|
||||
if (msg)
|
||||
{
|
||||
for (let rxRecord of msg.rxRecordList)
|
||||
{
|
||||
let [lat, lng] = WSPREncoded.DecodeMaidenheadToDeg(rxRecord.rxGrid);
|
||||
|
||||
let seenData = {
|
||||
sign: rxRecord.callsign,
|
||||
lat,
|
||||
lng,
|
||||
grid: rxRecord.rxGrid,
|
||||
};
|
||||
|
||||
seenDataList.push(seenData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// send along a cut-down version of the data available
|
||||
let tdSpot = new TabularData(this.td.MakeDataTableFromRow(row));
|
||||
let popupVisibleColSet = new Set([
|
||||
...WsprSearchUiDataTableVisibility.GetVisibleColumnsForStorageKey("dateTimeVisible"),
|
||||
...WsprSearchUiDataTableVisibility.GetVisibleColumnsForStorageKey("resolvedVisible"),
|
||||
]);
|
||||
let popupColList = tdSpot.GetHeaderList().filter(col => popupVisibleColSet.has(col));
|
||||
tdSpot.SetColumnOrder(popupColList);
|
||||
WsprSearchUiDataTableColumnOrder.Apply(tdSpot);
|
||||
tdSpot.DeleteEmptyColumns();
|
||||
|
||||
let spot = new this.mapModule.Spot({
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
grid: null,
|
||||
accuracy:
|
||||
(locationSource == "HRL" || locationSource == "EBT") ? "veryHigh" :
|
||||
(locationSource == "BT") ? "high" :
|
||||
"low",
|
||||
dtLocal: tdSpot.Get(0, "DateTimeLocal"),
|
||||
td: tdSpot,
|
||||
seenDataList: seenDataList,
|
||||
});
|
||||
|
||||
spotList.push(spot);
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
|
||||
// hand off even an empty spot list
|
||||
this.map.SetSpotList(spotList);
|
||||
|
||||
this.t.Event(`WsprSearchUiMapController::MapData End`);
|
||||
}
|
||||
|
||||
MakeUI()
|
||||
{
|
||||
let ui = document.createElement("div");
|
||||
|
||||
ui.style.boxSizing = "border-box";
|
||||
ui.style.border = "1px solid black";
|
||||
ui.style.width = "1210px";
|
||||
ui.style.height = "550px";
|
||||
|
||||
ui.style.resize = "both";
|
||||
ui.style.overflow = "hidden";
|
||||
|
||||
return ui;
|
||||
}
|
||||
}
|
||||
593
js/WsprSearchUiStatsFilterController.js
Normal file
593
js/WsprSearchUiStatsFilterController.js
Normal file
@@ -0,0 +1,593 @@
|
||||
/*
|
||||
Copyright (c) 2023-forever Douglas Malnati. All rights reserved.
|
||||
|
||||
See the /faq/tos page for details.
|
||||
|
||||
(If this generated header is stamped on a file which is a 3rd party file or under a different license or copyright, then ignore this copyright statement and use that file's terms.)
|
||||
*/
|
||||
|
||||
import { Base } from './Base.js';
|
||||
import { StrAccumulator } from '/js/Utl.js';
|
||||
import { AsyncResourceLoader } from './AsyncResourceLoader.js';
|
||||
|
||||
export class WsprSearchUiStatsFilterController
|
||||
extends Base
|
||||
{
|
||||
static urlEchartsScript = `https://fastly.jsdelivr.net/npm/echarts@5.4.2/dist/echarts.min.js`;
|
||||
|
||||
constructor(cfg)
|
||||
{
|
||||
super();
|
||||
|
||||
this.cfg = cfg;
|
||||
|
||||
this.ok =
|
||||
this.cfg.container &&
|
||||
this.cfg.wsprSearch;
|
||||
|
||||
this.chart = null;
|
||||
this.chartReady = false;
|
||||
this.chartLoadPromise = null;
|
||||
this.renderSankeyToken = 0;
|
||||
|
||||
if (this.ok)
|
||||
{
|
||||
this.ui = this.MakeUI();
|
||||
this.cfg.container.appendChild(this.ui);
|
||||
this.EnsureChartReady();
|
||||
}
|
||||
else
|
||||
{
|
||||
this.Err(`WsprSearchUiStatsFilterController`, `Could not init`);
|
||||
}
|
||||
}
|
||||
|
||||
OnEvent(evt)
|
||||
{
|
||||
if (this.ok)
|
||||
{
|
||||
switch (evt.type) {
|
||||
case "SEARCH_COMPLETE": this.OnSearchComplete(); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async OnSearchComplete()
|
||||
{
|
||||
let slotStatsList = this.ComputeSlotStats();
|
||||
this.RenderText(slotStatsList);
|
||||
this.ScheduleRenderSankey(slotStatsList);
|
||||
}
|
||||
|
||||
GetFlowSpecList()
|
||||
{
|
||||
return [
|
||||
{ label: "S0 Reg", slot: 0, type: "regular" },
|
||||
{ label: "S0 ET", slot: 0, type: "telemetry" },
|
||||
{ label: "S1 BT", slot: 1, telemetryType: "basic" },
|
||||
{ label: "S1 ET", slot: 1, telemetryType: "extended" },
|
||||
{ label: "S2 ET", slot: 2 },
|
||||
{ label: "S3 ET", slot: 3 },
|
||||
{ label: "S4 ET", slot: 4 },
|
||||
];
|
||||
}
|
||||
|
||||
MsgMatchesFlowSpec(msg, flowSpec)
|
||||
{
|
||||
if (!flowSpec.type)
|
||||
{
|
||||
if (!flowSpec.telemetryType)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (flowSpec.telemetryType == "basic")
|
||||
{
|
||||
return msg.IsTelemetryBasic();
|
||||
}
|
||||
|
||||
if (flowSpec.telemetryType == "extended")
|
||||
{
|
||||
return msg.IsTelemetryExtended();
|
||||
}
|
||||
|
||||
return msg.type == flowSpec.type;
|
||||
}
|
||||
|
||||
ComputeSlotStats()
|
||||
{
|
||||
let flowSpecList = this.GetFlowSpecList();
|
||||
let slotStatsList = [];
|
||||
for (const flowSpec of flowSpecList)
|
||||
{
|
||||
slotStatsList.push({
|
||||
label: flowSpec.label,
|
||||
flowSpec: flowSpec,
|
||||
input: 0,
|
||||
rejectedBadTelemetry: 0,
|
||||
rejectedBySpec: 0,
|
||||
rejectedByFingerprinting: 0,
|
||||
finalCandidate: 0,
|
||||
outcomeZeroCandidates: 0,
|
||||
outcomeOneCandidate: 0,
|
||||
outcomeMultiCandidate: 0,
|
||||
});
|
||||
}
|
||||
|
||||
this.cfg.wsprSearch.ForEachWindowMsgListList(msgListList => {
|
||||
for (let idx = 0; idx < slotStatsList.length; ++idx)
|
||||
{
|
||||
let s = slotStatsList[idx];
|
||||
let msgList = msgListList[s.flowSpec.slot];
|
||||
let msgListFiltered = msgList.filter(msg => this.MsgMatchesFlowSpec(msg, s.flowSpec));
|
||||
|
||||
for (const msg of msgListFiltered)
|
||||
{
|
||||
s.input += 1;
|
||||
|
||||
if (msg.IsCandidate())
|
||||
{
|
||||
s.finalCandidate += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let rejectType = this.GetRejectType(msg);
|
||||
if (rejectType == "ByBadTelemetry")
|
||||
{
|
||||
s.rejectedBadTelemetry += 1;
|
||||
}
|
||||
else if (rejectType == "BySpec")
|
||||
{
|
||||
s.rejectedBySpec += 1;
|
||||
}
|
||||
else if (rejectType == "ByFingerprinting")
|
||||
{
|
||||
s.rejectedByFingerprinting += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let candidateCount = 0;
|
||||
for (const msg of msgListFiltered)
|
||||
{
|
||||
if (msg.IsCandidate())
|
||||
{
|
||||
candidateCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (candidateCount == 0)
|
||||
{
|
||||
// Keep Sankey math consistent (message units across all stages):
|
||||
// zero-candidate windows contribute zero candidate messages.
|
||||
s.outcomeZeroCandidates += 0;
|
||||
}
|
||||
else if (candidateCount == 1)
|
||||
{
|
||||
s.outcomeOneCandidate += 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Multi-candidate windows contribute all remaining candidate messages.
|
||||
s.outcomeMultiCandidate += candidateCount;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const s of slotStatsList)
|
||||
{
|
||||
s.afterBadTelemetry = s.input - s.rejectedBadTelemetry;
|
||||
s.afterBySpec = s.afterBadTelemetry - s.rejectedBySpec;
|
||||
s.afterByFingerprinting = s.afterBySpec - s.rejectedByFingerprinting;
|
||||
if (s.afterByFingerprinting < 0)
|
||||
{
|
||||
s.afterByFingerprinting = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return slotStatsList;
|
||||
}
|
||||
|
||||
GetRejectType(msg)
|
||||
{
|
||||
let auditList = msg.candidateFilterAuditList || [];
|
||||
if (auditList.length == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
// By design, first reject in pipeline determines final state.
|
||||
return auditList[0].type || "";
|
||||
}
|
||||
|
||||
RenderText(slotStatsList)
|
||||
{
|
||||
let a = new StrAccumulator();
|
||||
let fmtPct = (num, den) => {
|
||||
let pct = 0;
|
||||
if (den > 0)
|
||||
{
|
||||
pct = Math.round((num / den) * 100);
|
||||
}
|
||||
|
||||
return `(${pct.toString().padStart(3)}%)`;
|
||||
};
|
||||
|
||||
a.A(`Filter Stats (Per Slot)`);
|
||||
a.A(`-----------------------`);
|
||||
for (let slot = 0; slot < slotStatsList.length; ++slot)
|
||||
{
|
||||
let s = slotStatsList[slot];
|
||||
let valueWidth = Math.max(
|
||||
1,
|
||||
s.input.toString().length,
|
||||
s.rejectedBadTelemetry.toString().length,
|
||||
s.rejectedBySpec.toString().length,
|
||||
s.rejectedByFingerprinting.toString().length,
|
||||
s.finalCandidate.toString().length,
|
||||
s.outcomeZeroCandidates.toString().length,
|
||||
s.outcomeOneCandidate.toString().length,
|
||||
s.outcomeMultiCandidate.toString().length,
|
||||
);
|
||||
let fmtVal = (v) => v.toString().padStart(valueWidth);
|
||||
|
||||
a.A(`${s.label}`);
|
||||
a.A(` Input : ${s.input}`);
|
||||
a.A(` Rejected BadTelemetry : ${fmtVal(s.rejectedBadTelemetry)} ${fmtPct(s.rejectedBadTelemetry, s.input)}`);
|
||||
a.A(` Rejected BySpec : ${fmtVal(s.rejectedBySpec)} ${fmtPct(s.rejectedBySpec, s.input)}`);
|
||||
a.A(` Rejected Fingerprint : ${fmtVal(s.rejectedByFingerprinting)} ${fmtPct(s.rejectedByFingerprinting, s.input)}`);
|
||||
a.A(` Final Candidate : ${fmtVal(s.finalCandidate)} ${fmtPct(s.finalCandidate, s.input)}`);
|
||||
a.A(` Outcome: 0 candidate : ${fmtVal(s.outcomeZeroCandidates)} ${fmtPct(s.outcomeZeroCandidates, s.input)}`);
|
||||
a.A(` Outcome: 1 candidate : ${fmtVal(s.outcomeOneCandidate)} ${fmtPct(s.outcomeOneCandidate, s.input)}`);
|
||||
a.A(` Outcome: 2+ candidates: ${fmtVal(s.outcomeMultiCandidate)} ${fmtPct(s.outcomeMultiCandidate, s.input)}`);
|
||||
}
|
||||
|
||||
this.ta.value = a.Get();
|
||||
}
|
||||
|
||||
async RenderSankey(slotStatsList)
|
||||
{
|
||||
await this.EnsureChartReady();
|
||||
if (!this.chartReady)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let nodeMap = new Map();
|
||||
let links = [];
|
||||
|
||||
let addNode = (name, depth = undefined) => {
|
||||
if (!nodeMap.has(name))
|
||||
{
|
||||
let node = { name };
|
||||
if (depth != undefined)
|
||||
{
|
||||
node.depth = depth;
|
||||
}
|
||||
nodeMap.set(name, node);
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the earliest stage depth if the node was already created.
|
||||
if (depth != undefined)
|
||||
{
|
||||
let node = nodeMap.get(name);
|
||||
if (node.depth == undefined || depth < node.depth)
|
||||
{
|
||||
node.depth = depth;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let addLink = (source, target, value) => {
|
||||
if (value <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
addNode(source);
|
||||
addNode(target);
|
||||
links.push({ source, target, value });
|
||||
};
|
||||
|
||||
for (let slot = 0; slot < slotStatsList.length; ++slot)
|
||||
{
|
||||
let s = slotStatsList[slot];
|
||||
let prefix = s.label;
|
||||
|
||||
let nInput = `${prefix}`;
|
||||
let nAfterBad = `${prefix} After BadTelemetry`;
|
||||
let nAfterSpec = `${prefix} After BySpec`;
|
||||
let nAfterFp = `${prefix} After ByFingerprinting`;
|
||||
|
||||
let nRejBad = `${prefix} Rejected BadTelemetry`;
|
||||
let nRejSpec = `${prefix} Rejected BySpec`;
|
||||
let nRejFp = `${prefix} Rejected ByFingerprinting`;
|
||||
let nOutcomeZero = `${prefix} Outcome: 0 Candidate`;
|
||||
let nOutcomeOne = `${prefix} Outcome: 1 Candidate`;
|
||||
let nOutcomeMulti = `${prefix} Outcome: 2+ Candidates`;
|
||||
|
||||
addNode(nInput, 0);
|
||||
addNode(nRejBad, 1);
|
||||
addNode(nAfterBad, 1);
|
||||
addNode(nRejSpec, 2);
|
||||
addNode(nAfterSpec, 2);
|
||||
addNode(nRejFp, 3);
|
||||
addNode(nAfterFp, 3);
|
||||
addNode(nOutcomeZero, 4);
|
||||
addNode(nOutcomeOne, 4);
|
||||
addNode(nOutcomeMulti, 4);
|
||||
|
||||
addLink(nInput, nRejBad, s.rejectedBadTelemetry);
|
||||
addLink(nInput, nAfterBad, s.afterBadTelemetry);
|
||||
|
||||
addLink(nAfterBad, nRejSpec, s.rejectedBySpec);
|
||||
addLink(nAfterBad, nAfterSpec, s.afterBySpec);
|
||||
|
||||
addLink(nAfterSpec, nRejFp, s.rejectedByFingerprinting);
|
||||
addLink(nAfterSpec, nAfterFp, s.afterByFingerprinting);
|
||||
|
||||
addLink(nAfterFp, nOutcomeZero, s.outcomeZeroCandidates);
|
||||
addLink(nAfterFp, nOutcomeOne, s.outcomeOneCandidate);
|
||||
addLink(nAfterFp, nOutcomeMulti, s.outcomeMultiCandidate);
|
||||
}
|
||||
|
||||
this.chart.setOption({
|
||||
title: {
|
||||
text: "Filter Pipeline",
|
||||
left: "left",
|
||||
},
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
triggerOn: "mousemove",
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "sankey",
|
||||
// Use automatic Sankey layout so each phase is positioned by graph depth.
|
||||
layoutIterations: 64,
|
||||
nodeAlign: "justify",
|
||||
nodeGap: 16,
|
||||
emphasis: {
|
||||
// Custom hover behavior below handles upstream-only highlighting.
|
||||
focus: "none",
|
||||
},
|
||||
data: Array.from(nodeMap.values()),
|
||||
links: links,
|
||||
lineStyle: {
|
||||
color: "source",
|
||||
curveness: 0.5,
|
||||
},
|
||||
},
|
||||
],
|
||||
animation: false,
|
||||
}, true);
|
||||
|
||||
this.#InstallUpstreamHover(nodeMap, links);
|
||||
}
|
||||
|
||||
ScheduleRenderSankey(slotStatsList)
|
||||
{
|
||||
let token = ++this.renderSankeyToken;
|
||||
|
||||
let run = async () => {
|
||||
if (token != this.renderSankeyToken)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await this.RenderSankey(slotStatsList);
|
||||
};
|
||||
|
||||
if (window.requestIdleCallback)
|
||||
{
|
||||
window.requestIdleCallback(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
run();
|
||||
});
|
||||
}, { timeout: 250 });
|
||||
}
|
||||
else
|
||||
{
|
||||
window.setTimeout(() => {
|
||||
window.requestAnimationFrame(() => {
|
||||
run();
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#InstallUpstreamHover(nodeMap, links)
|
||||
{
|
||||
if (!this.chart)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let nodeNameList = Array.from(nodeMap.keys());
|
||||
let nodeNameSet = new Set(nodeNameList);
|
||||
let nodeIdxByName = new Map();
|
||||
nodeNameList.forEach((name, idx) => nodeIdxByName.set(name, idx));
|
||||
|
||||
let incomingEdgeIdxByTarget = new Map();
|
||||
for (let i = 0; i < links.length; ++i)
|
||||
{
|
||||
let l = links[i];
|
||||
if (!incomingEdgeIdxByTarget.has(l.target))
|
||||
{
|
||||
incomingEdgeIdxByTarget.set(l.target, []);
|
||||
}
|
||||
incomingEdgeIdxByTarget.get(l.target).push(i);
|
||||
}
|
||||
|
||||
// Track highlighted items so we can downplay cleanly.
|
||||
this.sankeyHoverState = {
|
||||
nodeIdxSet: new Set(),
|
||||
edgeIdxSet: new Set(),
|
||||
};
|
||||
|
||||
let clearHighlight = () => {
|
||||
if (!this.sankeyHoverState)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (const idx of this.sankeyHoverState.nodeIdxSet)
|
||||
{
|
||||
this.chart.dispatchAction({
|
||||
type: "downplay",
|
||||
seriesIndex: 0,
|
||||
dataType: "node",
|
||||
dataIndex: idx,
|
||||
});
|
||||
}
|
||||
for (const idx of this.sankeyHoverState.edgeIdxSet)
|
||||
{
|
||||
this.chart.dispatchAction({
|
||||
type: "downplay",
|
||||
seriesIndex: 0,
|
||||
dataType: "edge",
|
||||
dataIndex: idx,
|
||||
});
|
||||
}
|
||||
|
||||
this.sankeyHoverState.nodeIdxSet.clear();
|
||||
this.sankeyHoverState.edgeIdxSet.clear();
|
||||
};
|
||||
|
||||
let applyUpstreamHighlight = (seedNameList) => {
|
||||
clearHighlight();
|
||||
|
||||
let seenNameSet = new Set();
|
||||
let stack = [...seedNameList];
|
||||
while (stack.length)
|
||||
{
|
||||
let cur = stack.pop();
|
||||
if (!cur || seenNameSet.has(cur))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
seenNameSet.add(cur);
|
||||
|
||||
let nodeIdx = nodeIdxByName.get(cur);
|
||||
if (nodeIdx != undefined)
|
||||
{
|
||||
this.sankeyHoverState.nodeIdxSet.add(nodeIdx);
|
||||
}
|
||||
|
||||
let edgeIdxList = incomingEdgeIdxByTarget.get(cur) || [];
|
||||
for (const edgeIdx of edgeIdxList)
|
||||
{
|
||||
this.sankeyHoverState.edgeIdxSet.add(edgeIdx);
|
||||
let src = links[edgeIdx].source;
|
||||
if (src && !seenNameSet.has(src))
|
||||
{
|
||||
stack.push(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const idx of this.sankeyHoverState.nodeIdxSet)
|
||||
{
|
||||
this.chart.dispatchAction({
|
||||
type: "highlight",
|
||||
seriesIndex: 0,
|
||||
dataType: "node",
|
||||
dataIndex: idx,
|
||||
});
|
||||
}
|
||||
for (const idx of this.sankeyHoverState.edgeIdxSet)
|
||||
{
|
||||
this.chart.dispatchAction({
|
||||
type: "highlight",
|
||||
seriesIndex: 0,
|
||||
dataType: "edge",
|
||||
dataIndex: idx,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
this.chart.off("mouseover");
|
||||
this.chart.off("globalout");
|
||||
|
||||
this.chart.on("mouseover", (params) => {
|
||||
if (!params || params.seriesType != "sankey")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (params.dataType == "node")
|
||||
{
|
||||
let name = params?.data?.name;
|
||||
if (nodeNameSet.has(name))
|
||||
{
|
||||
applyUpstreamHighlight([name]);
|
||||
}
|
||||
}
|
||||
else if (params.dataType == "edge")
|
||||
{
|
||||
// Upstream of an edge means upstream of its target.
|
||||
let target = params?.data?.target;
|
||||
if (nodeNameSet.has(target))
|
||||
{
|
||||
applyUpstreamHighlight([target]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.chart.on("globalout", () => {
|
||||
clearHighlight();
|
||||
});
|
||||
}
|
||||
|
||||
async EnsureChartReady()
|
||||
{
|
||||
if (this.chartReady)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.chartLoadPromise)
|
||||
{
|
||||
this.chartLoadPromise = AsyncResourceLoader.AsyncLoadScript(WsprSearchUiStatsFilterController.urlEchartsScript);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await this.chartLoadPromise;
|
||||
if (!this.chart)
|
||||
{
|
||||
this.chart = echarts.init(this.chartDiv);
|
||||
}
|
||||
this.chartReady = true;
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
this.Err(`WsprSearchUiStatsFilterController`, `Could not init chart: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
MakeUI()
|
||||
{
|
||||
let ui = document.createElement('div');
|
||||
|
||||
this.ta = document.createElement('textarea');
|
||||
this.ta.spellcheck = "false";
|
||||
this.ta.readOnly = true;
|
||||
this.ta.disabled = true;
|
||||
this.ta.style.width = "600px";
|
||||
this.ta.style.height = "260px";
|
||||
|
||||
this.chartDiv = document.createElement('div');
|
||||
this.chartDiv.style.boxSizing = "border-box";
|
||||
this.chartDiv.style.border = "1px solid black";
|
||||
this.chartDiv.style.width = "1210px";
|
||||
this.chartDiv.style.height = "800px";
|
||||
this.chartDiv.style.marginTop = "8px";
|
||||
|
||||
ui.appendChild(this.ta);
|
||||
ui.appendChild(this.chartDiv);
|
||||
|
||||
return ui;
|
||||
}
|
||||
}
|
||||
104
js/WsprSearchUiStatsSearchController.js
Normal file
104
js/WsprSearchUiStatsSearchController.js
Normal file
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
Copyright (c) 2023-forever Douglas Malnati. All rights reserved.
|
||||
|
||||
See the /faq/tos page for details.
|
||||
|
||||
(If this generated header is stamped on a file which is a 3rd party file or under a different license or copyright, then ignore this copyright statement and use that file's terms.)
|
||||
*/
|
||||
|
||||
import { Base } from './Base.js';
|
||||
import { StrAccumulator } from '/js/Utl.js';
|
||||
|
||||
|
||||
export class WsprSearchUiStatsSearchController
|
||||
extends Base
|
||||
{
|
||||
constructor(cfg)
|
||||
{
|
||||
super();
|
||||
|
||||
this.cfg = cfg;
|
||||
|
||||
this.ok =
|
||||
this.cfg.container &&
|
||||
this.cfg.wsprSearch;
|
||||
|
||||
if (this.ok)
|
||||
{
|
||||
this.ui = this.MakeUI();
|
||||
this.cfg.container.appendChild(this.ui);
|
||||
}
|
||||
else
|
||||
{
|
||||
this.Err(`WsprSearchUiStatsSearchController`, `Could not init`);
|
||||
console.log(this.cfg.container);
|
||||
console.log(this.cfg.wsprSearch);
|
||||
}
|
||||
}
|
||||
|
||||
OnEvent(evt)
|
||||
{
|
||||
if (this.ok)
|
||||
{
|
||||
switch (evt.type) {
|
||||
case "SEARCH_COMPLETE": this.OnSearchComplete(); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OnSearchComplete()
|
||||
{
|
||||
let stats = this.cfg.wsprSearch.GetStats();
|
||||
|
||||
let a = new StrAccumulator();
|
||||
|
||||
a.A(`Querying`);
|
||||
a.A(`--------`);
|
||||
a.A(` Slot 0 Regular - ms: ${stats.query.slot0Regular.durationMs.toString().padStart(4)}, rows: ${stats.query.slot0Regular.rowCount.toString().padStart(5)}, msgs: ${stats.query.slot0Regular.uniqueMsgCount.toString().padStart(4)}`);
|
||||
a.A(` Slot 0 Telemetry - ms: ${stats.query.slot0Telemetry.durationMs.toString().padStart(4)}, rows: ${stats.query.slot0Telemetry.rowCount.toString().padStart(5)}, msgs: ${stats.query.slot0Telemetry.uniqueMsgCount.toString().padStart(4)}`);
|
||||
a.A(` Slot 1 Telemetry - ms: ${stats.query.slot1Telemetry.durationMs.toString().padStart(4)}, rows: ${stats.query.slot1Telemetry.rowCount.toString().padStart(5)}, msgs: ${stats.query.slot1Telemetry.uniqueMsgCount.toString().padStart(4)}`);
|
||||
a.A(` Slot 2 Telemetry - ms: ${stats.query.slot2Telemetry.durationMs.toString().padStart(4)}, rows: ${stats.query.slot2Telemetry.rowCount.toString().padStart(5)}, msgs: ${stats.query.slot2Telemetry.uniqueMsgCount.toString().padStart(4)}`);
|
||||
a.A(` Slot 3 Telemetry - ms: ${stats.query.slot3Telemetry.durationMs.toString().padStart(4)}, rows: ${stats.query.slot3Telemetry.rowCount.toString().padStart(5)}, msgs: ${stats.query.slot3Telemetry.uniqueMsgCount.toString().padStart(4)}`);
|
||||
a.A(` Slot 4 Telemetry - ms: ${stats.query.slot4Telemetry.durationMs.toString().padStart(4)}, rows: ${stats.query.slot4Telemetry.rowCount.toString().padStart(5)}, msgs: ${stats.query.slot4Telemetry.uniqueMsgCount.toString().padStart(4)}`);
|
||||
a.A(``);
|
||||
a.A(`Processing`);
|
||||
a.A(`----------`);
|
||||
a.A(`SearchTotalMs : ${stats.processing.searchTotalMs.toString().padStart(4)}`);
|
||||
a.A(` DecodeMs : ${stats.processing.decodeMs.toString().padStart(4)}`);
|
||||
a.A(` FilterMs : ${stats.processing.filterMs.toString().padStart(4)}`);
|
||||
a.A(` DataTableMs : ${stats.processing.dataTableBuildMs.toString().padStart(4)}`);
|
||||
a.A(` UiRenderMs : ${stats.processing.uiRenderMs.toString().padStart(4)}`);
|
||||
a.A(` StatsGatherMs: ${stats.processing.statsGatherMs.toString().padStart(4)}`);
|
||||
a.A(``);
|
||||
a.A(`Results`);
|
||||
a.A(`-------`);
|
||||
a.A(` Total 10-min windows: ${stats.results.windowCount}`);
|
||||
a.A(` Slot 0 - msgs: ${stats.results.slot0.haveAnyMsgsPct.toString().padStart(3)} %, 0 ok pct: ${stats.results.slot0.noCandidatePct.toString().padStart(3)} %, 1 ok pct: ${stats.results.slot0.oneCandidatePct.toString().padStart(3)} %, 2+ ok pct: ${stats.results.slot0.multiCandidatePct.toString().padStart(3)} %`);
|
||||
a.A(` Slot 1 - msgs: ${stats.results.slot1.haveAnyMsgsPct.toString().padStart(3)} %, 0 ok pct: ${stats.results.slot1.noCandidatePct.toString().padStart(3)} %, 1 ok pct: ${stats.results.slot1.oneCandidatePct.toString().padStart(3)} %, 2+ ok pct: ${stats.results.slot1.multiCandidatePct.toString().padStart(3)} %`);
|
||||
a.A(` Slot 2 - msgs: ${stats.results.slot2.haveAnyMsgsPct.toString().padStart(3)} %, 0 ok pct: ${stats.results.slot2.noCandidatePct.toString().padStart(3)} %, 1 ok pct: ${stats.results.slot2.oneCandidatePct.toString().padStart(3)} %, 2+ ok pct: ${stats.results.slot2.multiCandidatePct.toString().padStart(3)} %`);
|
||||
a.A(` Slot 3 - msgs: ${stats.results.slot3.haveAnyMsgsPct.toString().padStart(3)} %, 0 ok pct: ${stats.results.slot3.noCandidatePct.toString().padStart(3)} %, 1 ok pct: ${stats.results.slot3.oneCandidatePct.toString().padStart(3)} %, 2+ ok pct: ${stats.results.slot3.multiCandidatePct.toString().padStart(3)} %`);
|
||||
a.A(` Slot 4 - msgs: ${stats.results.slot4.haveAnyMsgsPct.toString().padStart(3)} %, 0 ok pct: ${stats.results.slot4.noCandidatePct.toString().padStart(3)} %, 1 ok pct: ${stats.results.slot4.oneCandidatePct.toString().padStart(3)} %, 2+ ok pct: ${stats.results.slot4.multiCandidatePct.toString().padStart(3)} %`);
|
||||
|
||||
this.ta.value = a.Get();
|
||||
}
|
||||
|
||||
MakeUI()
|
||||
{
|
||||
let ui = document.createElement('div');
|
||||
|
||||
let ta = document.createElement('textarea');
|
||||
ta.spellcheck = "false";
|
||||
ta.readOnly = true;
|
||||
ta.disabled = true;
|
||||
ta.style.width = "600px";
|
||||
ta.style.height = "400px";
|
||||
|
||||
this.ta = ta;
|
||||
|
||||
ui.appendChild(ta);
|
||||
|
||||
return ui;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
0
js/dat.gui.min.js
vendored
Normal file
0
js/dat.gui.min.js
vendored
Normal file
0
js/echarts.min.js
vendored
Normal file
0
js/echarts.min.js
vendored
Normal file
325
js/suncalc.js
Normal file
325
js/suncalc.js
Normal file
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
Copyright (c) 2023-forever Douglas Malnati. All rights reserved.
|
||||
|
||||
See the /faq/tos page for details.
|
||||
|
||||
(If this generated header is stamped on a file which is a 3rd party file or under a different license or copyright, then ignore this copyright statement and use that file's terms.)
|
||||
*/
|
||||
|
||||
/*
|
||||
(c) 2011-2015, Vladimir Agafonkin
|
||||
SunCalc is a JavaScript library for calculating sun/moon position and light phases.
|
||||
https://github.com/mourner/suncalc
|
||||
*/
|
||||
|
||||
(function () { 'use strict';
|
||||
|
||||
// shortcuts for easier to read formulas
|
||||
|
||||
var PI = Math.PI,
|
||||
sin = Math.sin,
|
||||
cos = Math.cos,
|
||||
tan = Math.tan,
|
||||
asin = Math.asin,
|
||||
atan = Math.atan2,
|
||||
acos = Math.acos,
|
||||
rad = PI / 180;
|
||||
|
||||
// sun calculations are based on http://aa.quae.nl/en/reken/zonpositie.html formulas
|
||||
|
||||
|
||||
// date/time constants and conversions
|
||||
|
||||
var dayMs = 1000 * 60 * 60 * 24,
|
||||
J1970 = 2440588,
|
||||
J2000 = 2451545;
|
||||
|
||||
function toJulian(date) { return date.valueOf() / dayMs - 0.5 + J1970; }
|
||||
function fromJulian(j) { return new Date((j + 0.5 - J1970) * dayMs); }
|
||||
function toDays(date) { return toJulian(date) - J2000; }
|
||||
|
||||
|
||||
// general calculations for position
|
||||
|
||||
var e = rad * 23.4397; // obliquity of the Earth
|
||||
|
||||
function rightAscension(l, b) { return atan(sin(l) * cos(e) - tan(b) * sin(e), cos(l)); }
|
||||
function declination(l, b) { return asin(sin(b) * cos(e) + cos(b) * sin(e) * sin(l)); }
|
||||
|
||||
function azimuth(H, phi, dec) { return atan(sin(H), cos(H) * sin(phi) - tan(dec) * cos(phi)); }
|
||||
function altitude(H, phi, dec) { return asin(sin(phi) * sin(dec) + cos(phi) * cos(dec) * cos(H)); }
|
||||
|
||||
function siderealTime(d, lw) { return rad * (280.16 + 360.9856235 * d) - lw; }
|
||||
|
||||
function astroRefraction(h) {
|
||||
if (h < 0) // the following formula works for positive altitudes only.
|
||||
h = 0; // if h = -0.08901179 a div/0 would occur.
|
||||
|
||||
// formula 16.4 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998.
|
||||
// 1.02 / tan(h + 10.26 / (h + 5.10)) h in degrees, result in arc minutes -> converted to rad:
|
||||
return 0.0002967 / Math.tan(h + 0.00312536 / (h + 0.08901179));
|
||||
}
|
||||
|
||||
// general sun calculations
|
||||
|
||||
function solarMeanAnomaly(d) { return rad * (357.5291 + 0.98560028 * d); }
|
||||
|
||||
function eclipticLongitude(M) {
|
||||
|
||||
var C = rad * (1.9148 * sin(M) + 0.02 * sin(2 * M) + 0.0003 * sin(3 * M)), // equation of center
|
||||
P = rad * 102.9372; // perihelion of the Earth
|
||||
|
||||
return M + C + P + PI;
|
||||
}
|
||||
|
||||
function sunCoords(d) {
|
||||
|
||||
var M = solarMeanAnomaly(d),
|
||||
L = eclipticLongitude(M);
|
||||
|
||||
return {
|
||||
dec: declination(L, 0),
|
||||
ra: rightAscension(L, 0)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
var SunCalc = {};
|
||||
|
||||
|
||||
// calculates sun position for a given date and latitude/longitude
|
||||
|
||||
SunCalc.getPosition = function (date, lat, lng) {
|
||||
|
||||
var lw = rad * -lng,
|
||||
phi = rad * lat,
|
||||
d = toDays(date),
|
||||
|
||||
c = sunCoords(d),
|
||||
H = siderealTime(d, lw) - c.ra;
|
||||
|
||||
return {
|
||||
azimuth: azimuth(H, phi, c.dec),
|
||||
altitude: altitude(H, phi, c.dec)
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
// sun times configuration (angle, morning name, evening name)
|
||||
|
||||
var times = SunCalc.times = [
|
||||
[-0.833, 'sunrise', 'sunset' ],
|
||||
[ -0.3, 'sunriseEnd', 'sunsetStart' ],
|
||||
[ -6, 'dawn', 'dusk' ],
|
||||
[ -12, 'nauticalDawn', 'nauticalDusk'],
|
||||
[ -18, 'nightEnd', 'night' ],
|
||||
[ 6, 'goldenHourEnd', 'goldenHour' ]
|
||||
];
|
||||
|
||||
// adds a custom time to the times config
|
||||
|
||||
SunCalc.addTime = function (angle, riseName, setName) {
|
||||
times.push([angle, riseName, setName]);
|
||||
};
|
||||
|
||||
|
||||
// calculations for sun times
|
||||
|
||||
var J0 = 0.0009;
|
||||
|
||||
function julianCycle(d, lw) { return Math.round(d - J0 - lw / (2 * PI)); }
|
||||
|
||||
function approxTransit(Ht, lw, n) { return J0 + (Ht + lw) / (2 * PI) + n; }
|
||||
function solarTransitJ(ds, M, L) { return J2000 + ds + 0.0053 * sin(M) - 0.0069 * sin(2 * L); }
|
||||
|
||||
function hourAngle(h, phi, d) { return acos((sin(h) - sin(phi) * sin(d)) / (cos(phi) * cos(d))); }
|
||||
function observerAngle(height) { return -2.076 * Math.sqrt(height) / 60; }
|
||||
|
||||
// returns set time for the given sun altitude
|
||||
function getSetJ(h, lw, phi, dec, n, M, L) {
|
||||
|
||||
var w = hourAngle(h, phi, dec),
|
||||
a = approxTransit(w, lw, n);
|
||||
return solarTransitJ(a, M, L);
|
||||
}
|
||||
|
||||
|
||||
// calculates sun times for a given date, latitude/longitude, and, optionally,
|
||||
// the observer height (in meters) relative to the horizon
|
||||
|
||||
SunCalc.getTimes = function (date, lat, lng, height) {
|
||||
|
||||
height = height || 0;
|
||||
|
||||
var lw = rad * -lng,
|
||||
phi = rad * lat,
|
||||
|
||||
dh = observerAngle(height),
|
||||
|
||||
d = toDays(date),
|
||||
n = julianCycle(d, lw),
|
||||
ds = approxTransit(0, lw, n),
|
||||
|
||||
M = solarMeanAnomaly(ds),
|
||||
L = eclipticLongitude(M),
|
||||
dec = declination(L, 0),
|
||||
|
||||
Jnoon = solarTransitJ(ds, M, L),
|
||||
|
||||
i, len, time, h0, Jset, Jrise;
|
||||
|
||||
|
||||
var result = {
|
||||
solarNoon: fromJulian(Jnoon),
|
||||
nadir: fromJulian(Jnoon - 0.5)
|
||||
};
|
||||
|
||||
for (i = 0, len = times.length; i < len; i += 1) {
|
||||
time = times[i];
|
||||
h0 = (time[0] + dh) * rad;
|
||||
|
||||
Jset = getSetJ(h0, lw, phi, dec, n, M, L);
|
||||
Jrise = Jnoon - (Jset - Jnoon);
|
||||
|
||||
result[time[1]] = fromJulian(Jrise);
|
||||
result[time[2]] = fromJulian(Jset);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
|
||||
// moon calculations, based on http://aa.quae.nl/en/reken/hemelpositie.html formulas
|
||||
|
||||
function moonCoords(d) { // geocentric ecliptic coordinates of the moon
|
||||
|
||||
var L = rad * (218.316 + 13.176396 * d), // ecliptic longitude
|
||||
M = rad * (134.963 + 13.064993 * d), // mean anomaly
|
||||
F = rad * (93.272 + 13.229350 * d), // mean distance
|
||||
|
||||
l = L + rad * 6.289 * sin(M), // longitude
|
||||
b = rad * 5.128 * sin(F), // latitude
|
||||
dt = 385001 - 20905 * cos(M); // distance to the moon in km
|
||||
|
||||
return {
|
||||
ra: rightAscension(l, b),
|
||||
dec: declination(l, b),
|
||||
dist: dt
|
||||
};
|
||||
}
|
||||
|
||||
SunCalc.getMoonPosition = function (date, lat, lng) {
|
||||
|
||||
var lw = rad * -lng,
|
||||
phi = rad * lat,
|
||||
d = toDays(date),
|
||||
|
||||
c = moonCoords(d),
|
||||
H = siderealTime(d, lw) - c.ra,
|
||||
h = altitude(H, phi, c.dec),
|
||||
// formula 14.1 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998.
|
||||
pa = atan(sin(H), tan(phi) * cos(c.dec) - sin(c.dec) * cos(H));
|
||||
|
||||
h = h + astroRefraction(h); // altitude correction for refraction
|
||||
|
||||
return {
|
||||
azimuth: azimuth(H, phi, c.dec),
|
||||
altitude: h,
|
||||
distance: c.dist,
|
||||
parallacticAngle: pa
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
// calculations for illumination parameters of the moon,
|
||||
// based on http://idlastro.gsfc.nasa.gov/ftp/pro/astro/mphase.pro formulas and
|
||||
// Chapter 48 of "Astronomical Algorithms" 2nd edition by Jean Meeus (Willmann-Bell, Richmond) 1998.
|
||||
|
||||
SunCalc.getMoonIllumination = function (date) {
|
||||
|
||||
var d = toDays(date || new Date()),
|
||||
s = sunCoords(d),
|
||||
m = moonCoords(d),
|
||||
|
||||
sdist = 149598000, // distance from Earth to Sun in km
|
||||
|
||||
phi = acos(sin(s.dec) * sin(m.dec) + cos(s.dec) * cos(m.dec) * cos(s.ra - m.ra)),
|
||||
inc = atan(sdist * sin(phi), m.dist - sdist * cos(phi)),
|
||||
angle = atan(cos(s.dec) * sin(s.ra - m.ra), sin(s.dec) * cos(m.dec) -
|
||||
cos(s.dec) * sin(m.dec) * cos(s.ra - m.ra));
|
||||
|
||||
return {
|
||||
fraction: (1 + cos(inc)) / 2,
|
||||
phase: 0.5 + 0.5 * inc * (angle < 0 ? -1 : 1) / Math.PI,
|
||||
angle: angle
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
function hoursLater(date, h) {
|
||||
return new Date(date.valueOf() + h * dayMs / 24);
|
||||
}
|
||||
|
||||
// calculations for moon rise/set times are based on http://www.stargazing.net/kepler/moonrise.html article
|
||||
|
||||
SunCalc.getMoonTimes = function (date, lat, lng, inUTC) {
|
||||
var t = new Date(date);
|
||||
if (inUTC) t.setUTCHours(0, 0, 0, 0);
|
||||
else t.setHours(0, 0, 0, 0);
|
||||
|
||||
var hc = 0.133 * rad,
|
||||
h0 = SunCalc.getMoonPosition(t, lat, lng).altitude - hc,
|
||||
h1, h2, rise, set, a, b, xe, ye, d, roots, x1, x2, dx;
|
||||
|
||||
// go in 2-hour chunks, each time seeing if a 3-point quadratic curve crosses zero (which means rise or set)
|
||||
for (var i = 1; i <= 24; i += 2) {
|
||||
h1 = SunCalc.getMoonPosition(hoursLater(t, i), lat, lng).altitude - hc;
|
||||
h2 = SunCalc.getMoonPosition(hoursLater(t, i + 1), lat, lng).altitude - hc;
|
||||
|
||||
a = (h0 + h2) / 2 - h1;
|
||||
b = (h2 - h0) / 2;
|
||||
xe = -b / (2 * a);
|
||||
ye = (a * xe + b) * xe + h1;
|
||||
d = b * b - 4 * a * h1;
|
||||
roots = 0;
|
||||
|
||||
if (d >= 0) {
|
||||
dx = Math.sqrt(d) / (Math.abs(a) * 2);
|
||||
x1 = xe - dx;
|
||||
x2 = xe + dx;
|
||||
if (Math.abs(x1) <= 1) roots++;
|
||||
if (Math.abs(x2) <= 1) roots++;
|
||||
if (x1 < -1) x1 = x2;
|
||||
}
|
||||
|
||||
if (roots === 1) {
|
||||
if (h0 < 0) rise = i + x1;
|
||||
else set = i + x1;
|
||||
|
||||
} else if (roots === 2) {
|
||||
rise = i + (ye < 0 ? x2 : x1);
|
||||
set = i + (ye < 0 ? x1 : x2);
|
||||
}
|
||||
|
||||
if (rise && set) break;
|
||||
|
||||
h0 = h2;
|
||||
}
|
||||
|
||||
var result = {};
|
||||
|
||||
if (rise) result.rise = hoursLater(t, rise);
|
||||
if (set) result.set = hoursLater(t, set);
|
||||
|
||||
if (!rise && !set) result[ye > 0 ? 'alwaysUp' : 'alwaysDown'] = true;
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
|
||||
// export as Node module / AMD module / browser variable
|
||||
if (typeof exports === 'object' && typeof module !== 'undefined') module.exports = SunCalc;
|
||||
else if (typeof define === 'function' && define.amd) define(SunCalc);
|
||||
else window.SunCalc = SunCalc;
|
||||
|
||||
}());
|
||||
Reference in New Issue
Block a user