Commit pirate JS files

This commit is contained in:
2026-04-02 17:39:02 -06:00
parent 7b15a0eb9c
commit d287f8a443
49 changed files with 19149 additions and 0 deletions

39
js/Animation.js Normal file
View 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
View 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
View 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
View 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
View 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);
}
}

View 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`);
}
}
}
}
}
}

View 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
View 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);
}
}
};
}

View 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.`
);
}
}
}
}
}

View 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}).`
);
}
}
}
}
}

View 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

File diff suppressed because it is too large Load Diff

View 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
View 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
View 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
View 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
View 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
};
}
};

View 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

File diff suppressed because it is too large Load Diff

49
js/SpotMapAsyncLoader.js Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

250
js/WsprMessageCandidate.js Normal file
View 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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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,
};
}
}

View 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?.());
}
}

View 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,
};
}
}

View 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,
];
}
}

View 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,
];
}
}

View File

@@ -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
View 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,
});
}
}

View 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",
};
}
}

View 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());
}
}

File diff suppressed because it is too large Load Diff

View 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);
}
}
}
}
}

View 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;
}
}

View 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;
}
}

File diff suppressed because it is too large Load Diff

View 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;
}
}

View 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;
}
}

View 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
View File

0
js/echarts.min.js vendored Normal file
View File

0
js/ol.js Normal file
View File

325
js/suncalc.js Normal file
View 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;
}());