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