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