Files
protoloon/js/WsprSearchUiChartsController.js
2026-04-02 17:39:02 -06:00

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