927 lines
25 KiB
JavaScript
927 lines
25 KiB
JavaScript
/*
|
|
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",
|
|
};
|
|
}
|
|
}
|