/*
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 { CSSDynamic } from './CSSDynamic.js';
import { TabularData } from '../../../../js/TabularData.js';
import { WSPREncoded } from '/js/WSPREncoded.js';
import { WsprSearchUiDataTableRowProController } from './WsprSearchUiDataTableRowProController.js';
import { WsprSearchUiDataTableVisibility } from './WsprSearchUiDataTableVisibility.js';
import { WsprSearchUiDataTableColumnOrder } from './WsprSearchUiDataTableColumnOrder.js';
export class WsprSearchUiDataTableController
extends Base
{
static DETAILS_COOKIE_NAME = "wsprSearchDataTableControlsOpen";
static UI_UPGRADE_VERSION_KEY = "wsprSearchDataTableUiUpgradeVersion";
static UI_UPGRADE_VERSION = 2;
constructor(cfg)
{
super();
this.cfg = cfg;
this.wsprSearch = this.cfg.wsprSearch || null;
this.ok = this.cfg.container;
this.msgDefinitionUserDefinedList = new Array(5).fill("");
this.msgDefinitionVendorDefinedList = new Array(5).fill("");
// initialize display/data state before controls are created
// so persisted control callbacks do not get overwritten later.
this.table = document.createElement('table');
this.band = "20m";
this.channel = "";
this.channelPadded = "000";
this.callsign = "unset";
this.gte = "";
this.lte = "";
this.td = new TabularData();
this.tdSource = null;
this.showProColumn = false;
this.renderTableToken = 0;
this.pendingRenderAfterCharts = null;
this.pendingRenderAfterChartsFallbackId = null;
this.pendingJumpToDataTs = null;
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 "SEARCH_REQUESTED": this.#OnSearchRequest(evt); break;
case "DATA_TABLE_RAW_READY": this.OnDataTableRawReady(evt); break;
case "CHARTS_RENDER_COMPLETE": this.#OnChartsRenderComplete(); break;
case "JUMP_TO_DATA": this.#OnJumpToData(evt); break;
}
}
}
#OnSearchRequest(evt)
{
this.band = evt.band;
this.channel = evt.channel;
this.channelPadded = ("000" + evt.channel).slice(-3);
this.callsign = evt.callsign;
this.gte = evt.gte;
this.lte = evt.lteRaw;
this.#UpdateDownloadAndCopyLinks();
}
OnDataTableRawReady(evt)
{
this.t.Reset();
let t1 = this.t.Event(`WsprSearchUiDataTableController::OnDataTableRawReady Start`);
// clear existing child nodes
this.cfg.container.innerHTML = "";
this.tdSource = evt.tabularDataReadOnly;
this.cfg.container.appendChild(this.ui);
let token = ++this.renderTableToken;
let run = () => {
if (token != this.renderTableToken)
{
return;
}
this.#RenderDataTable();
let t2 = this.t.Event(`WsprSearchUiDataTableController::OnDataTableRawReady End`);
let stats = this.wsprSearch?.GetStats?.();
if (stats?.processing)
{
stats.processing.uiRenderMs = Math.round(t2 - t1);
}
};
this.pendingRenderAfterCharts = {
token,
run,
};
if (this.pendingRenderAfterChartsFallbackId != null)
{
window.clearTimeout(this.pendingRenderAfterChartsFallbackId);
}
this.pendingRenderAfterChartsFallbackId = window.setTimeout(() => {
this.#OnChartsRenderComplete();
}, 2000);
}
#OnChartsRenderComplete()
{
if (!this.pendingRenderAfterCharts)
{
return;
}
if (this.pendingRenderAfterChartsFallbackId != null)
{
window.clearTimeout(this.pendingRenderAfterChartsFallbackId);
this.pendingRenderAfterChartsFallbackId = null;
}
let { token, run } = this.pendingRenderAfterCharts;
this.pendingRenderAfterCharts = null;
let scheduleRun = () => {
if (token != this.renderTableToken)
{
return;
}
if (window.requestIdleCallback)
{
window.requestIdleCallback(() => {
window.requestAnimationFrame(run);
}, { timeout: 500 });
}
else
{
window.setTimeout(() => {
window.requestAnimationFrame(run);
}, 0);
}
};
window.requestAnimationFrame(scheduleRun);
}
#OnJumpToData(evt)
{
this.pendingJumpToDataTs = evt.ts ?? null;
this.#TryHandlePendingJumpToData();
}
SetMsgDefinitionUserDefinedList(msgDefinitionUserDefinedList)
{
this.msgDefinitionUserDefinedList = msgDefinitionUserDefinedList;
}
SetMsgDefinitionVendorDefinedList(msgDefinitionVendorDefinedList)
{
this.msgDefinitionVendorDefinedList = msgDefinitionVendorDefinedList;
}
ModifyTableContentsForDisplay(td)
{
this.FormatNumbers(td);
this.Linkify(td);
this.LinkifyUserOrVendorDefined(td, "ud");
this.LinkifyUserOrVendorDefined(td, "vd");
this.StyleOverlapData(td);
this.AddProColumnForDisplay(td);
this.PrioritizeColumnOrder(td);
}
AddProColumnForDisplay(td)
{
td.PrependGeneratedColumns([
"Pro"
], row => {
return [`🧠`];
});
}
FormatNumbers(td)
{
let colList = td.GetHeaderList();
let colInfoList = [];
for (const col of colList)
{
let idx = td.Idx(col);
if (idx == undefined)
{
continue;
}
let useDecimals = false;
for (let rowIdx = 1; rowIdx < td.dataTable.length; ++rowIdx)
{
let val = td.dataTable[rowIdx][idx];
if (typeof val == "number")
{
if (val.toString().indexOf('.') != -1)
{
useDecimals = true;
break;
}
}
}
let decimalPlaceLen = 3;
if (col == "Voltage" || col == "BtVoltage" || col == "EbtVoltage")
{
decimalPlaceLen = 2;
}
else if (col == "Lat" || col == "Lng" ||
col == "RegLat" || col == "RegLng" ||
col == "BtLat" || col == "BtLng" ||
col == "EbtLat" || col == "EbtLng" ||
col == "HiResLat" || col == "HiResLng")
{
decimalPlaceLen = 6;
}
else if (col == "TxFreqMhz")
{
decimalPlaceLen = 6;
}
colInfoList.push({
col,
idx,
useDecimals,
decimalPlaceLen,
});
}
for (let rowIdx = 1; rowIdx < td.dataTable.length; ++rowIdx)
{
let row = td.dataTable[rowIdx];
for (const colInfo of colInfoList)
{
let val = row[colInfo.idx];
if (typeof val != "number")
{
continue;
}
if (colInfo.useDecimals)
{
let precision = td.GetRowMetaData(rowIdx - 1)?.overlap?.style?.precisionByCol?.[colInfo.col];
if (precision == undefined)
{
precision = colInfo.decimalPlaceLen;
}
val = val.toFixed(precision);
}
row[colInfo.idx] = utl.Commas(val);
}
}
}
StyleOverlapData(td)
{
let colList = td.GetHeaderList();
for (let col of colList)
{
td.GenerateModifiedColumn([
col
], (row, idx) => {
let val = td.Get(row, col);
if (val == null || val === "")
{
return [val];
}
let overlapStyle = td.GetRowMetaData(idx)?.overlap?.style;
let dimmed = overlapStyle?.dimmedCols?.has?.(col) ?? false;
let italic = overlapStyle?.italicCols?.has?.(col) ?? false;
if (!dimmed && !italic)
{
return [val];
}
let classList = [];
if (dimmed) { classList.push("overlapDimmed"); }
if (italic) { classList.push("overlapItalic"); }
return [`${val}`];
});
}
}
Linkify(td)
{
let linkifyLatLngPair = (latCol, lngCol) => {
if (td.Idx(latCol) == undefined || td.Idx(lngCol) == undefined)
{
return;
}
let rawPairByRowIdx = [];
td.ForEach((row, idx) => {
rawPairByRowIdx[idx] = {
latVal: td.Get(row, latCol),
lngVal: td.Get(row, lngCol),
};
});
let makeLinkedVal = (row, rowIdx, colToRender) => {
let latVal = rawPairByRowIdx[rowIdx]?.latVal;
let lngVal = rawPairByRowIdx[rowIdx]?.lngVal;
if (latVal == null || latVal === "" || lngVal == null || lngVal === "")
{
return [td.Get(row, colToRender)];
}
let latNum = Number(String(latVal).replaceAll(",", ""));
let lngNum = Number(String(lngVal).replaceAll(",", ""));
if (!Number.isFinite(latNum) || !Number.isFinite(lngNum))
{
return [td.Get(row, colToRender)];
}
let gmUrl = WSPREncoded.MakeGoogleMapsLink(latNum, lngNum);
return [`${td.Get(row, colToRender)}`];
};
td.GenerateModifiedColumn([latCol], (row, idx) => makeLinkedVal(row, idx, latCol));
td.GenerateModifiedColumn([lngCol], (row, idx) => makeLinkedVal(row, idx, lngCol));
};
if (td.Idx("Grid"))
{
// linkify grid
td.GenerateModifiedColumn([
"Grid"
], row => {
let grid = td.Get(row, "Grid");
let retVal = [grid];
if (grid)
{
let [lat, lng] = WSPREncoded.DecodeMaidenheadToDeg(grid);
let gmUrl = WSPREncoded.MakeGoogleMapsLink(lat, lng);
let gridLink = `${grid}`;
retVal = [gridLink];
}
return retVal;
});
}
if (td.Idx("RegGrid"))
{
// linkify grid4
td.GenerateModifiedColumn([
"RegGrid"
], row => {
let grid4 = td.Get(row, "RegGrid");
let retVal = [grid4];
if (grid4)
{
let [lat, lng] = WSPREncoded.DecodeMaidenheadToDeg(grid4);
let gmUrl = WSPREncoded.MakeGoogleMapsLink(lat, lng);
let grid4Link = `${grid4}`;
retVal = [grid4Link];
}
return retVal;
});
}
if (td.Idx("BtGrid6"))
{
td.GenerateModifiedColumn([
"BtGrid6"
], row => {
let grid6 = td.Get(row, "BtGrid6");
let retVal = [grid6];
if (grid6)
{
let [lat, lng] = WSPREncoded.DecodeMaidenheadToDeg(grid6);
let gmUrl = WSPREncoded.MakeGoogleMapsLink(lat, lng);
let grid6Link = `${grid6}`;
retVal = [grid6Link];
}
return retVal;
});
}
[
["Lat", "Lng"],
["RegLat", "RegLng"],
["BtLat", "BtLng"],
["EbtLat", "EbtLng"],
["HiResLat", "HiResLng"],
].forEach(([latCol, lngCol]) => linkifyLatLngPair(latCol, lngCol));
}
LinkifyUserOrVendorDefined(td, type)
{
for (let slot = 0; slot < 5; ++slot)
{
let colName = `slot${slot}.${type}.EncMsg`;
let msgDef = type == "ud" ?
this.msgDefinitionUserDefinedList[slot] :
this.msgDefinitionVendorDefinedList[slot];
if (td.Idx(colName))
{
td.GenerateModifiedColumn([
colName
], row => {
let val = td.Get(row, colName);
let retVal = [val];
if (val)
{
let link = ``;
link += `/pro/codec/`;
link += `?codec=${encodeURIComponent(msgDef)}`;
link += `&decode=${val}`;
link += `&encode=`;
let a = `${val}`;
retVal = [a];
}
return retVal;
});
}
}
}
PrioritizeColumnOrder(td)
{
WsprSearchUiDataTableColumnOrder.Apply(td);
}
ModifyWebpageFormatting(td, table)
{
this.ModifyWebpageFormattingBasic(td, table);
this.ModifyWebpageFormattingExtendedUserAndVendorDefined(table);
}
ModifyWebpageFormattingBasic(td, table)
{
let cd = new CSSDynamic();
// column header colors
for (let ccName of ["Pro"])
{
cd.SetCssClassProperties(`${ccName}_hdr`, {
backgroundColor: "lemonchiffon",
});
}
for (let ccName of ["RegCall", "RegGrid", "RegPower", "RegLat", "RegLng"])
{
cd.SetCssClassProperties(`${ccName}_hdr`, {
backgroundColor: "lightgreen",
});
}
for (let ccName of ["BtGpsValid", "BtGrid56", "BtGrid6", "BtLat", "BtLng", "BtKPH", "BtMPH"])
{
cd.SetCssClassProperties(`${ccName}_hdr`, {
backgroundColor: "lightpink",
});
}
for (let ccName of ["BtTempC", "BtTempF", "BtVoltage", "BtAltM", "BtAltFt"])
{
cd.SetCssClassProperties(`${ccName}_hdr`, {
backgroundColor: "lightpink",
});
}
// Heartbeat
for (let ccName of ["TxFreqHzIdx", "TxFreqMhz", "UptimeMinutes", "GpsLockType", "GpsTryLockSeconds", "GpsSatsInViewCount"])
{
cd.SetCssClassProperties(`${ccName}_hdr`, {
backgroundColor: "lavenderblush",
});
}
for (let ccName of ["Lat", "Lng", "TempF", "TempC", "Voltage", "AltFt", "AltM", "KPH", "MPH", "AltChgFpm", "GpsMPH", "DistMi", "AltChgMpm", "GpsKPH", "DistKm", "SolAngle", "RxStationCount", "WinFreqDrift"])
{
cd.SetCssClassProperties(`${ccName}_hdr`, {
backgroundColor: "khaki",
});
}
for (let ccName of ["EbtTempF", "EbtTempC", "EbtVoltage", "EbtGpsValid", "EbtLatitudeIdx", "EbtLongitudeIdx", "EbtLat", "EbtLng", "EbtAltFt", "EbtAltM"])
{
cd.SetCssClassProperties(`${ccName}_hdr`, {
backgroundColor: "moccasin",
});
}
for (let ccName of ["HiResReference", "HiResLatitudeIdx", "HiResLongitudeIdx", "HiResLat", "HiResLng"])
{
cd.SetCssClassProperties(`${ccName}_hdr`, {
backgroundColor: "lavender",
});
}
cd.SetCssClassProperties(`overlapDimmed`, {
opacity: "0.55",
color: "#666",
});
cd.SetCssClassProperties(`overlapItalic`, {
fontStyle: "italic",
});
// give table a border
table.classList.add(`DataTable`);
cd.SetCssClassProperties(`DataTable`, {
border: "1px solid black",
borderSpacing: 0,
// using this breaks the nice sticky header
// borderCollapse: "collapse",
borderCollapse: "separate",
});
// make sticky header
let trHeader = table.tHead.rows[0];
trHeader.classList.add(`DataTableHeader`);
cd.SetCssClassProperties(`DataTableHeader`, {
border: "1px solid black",
top: "0px",
position: "sticky",
background: "white",
});
// make data rows highlight when you mouse over them
cd.SetCssClassProperties(`DataTable tr:hover`, {
backgroundColor: "rgb(215, 237, 255)",
});
// look for every column header class name that applies to the column.
// ie column classes that end with _col.
let colClassList = [];
const colGroupList = table.querySelectorAll('colgroup'); // should just be the one
for (let colGroup of colGroupList)
{
for (let childNode of colGroup.childNodes)
{
for (let className of childNode.classList)
{
let suffix = className.slice(-4);
if (suffix == "_col")
{
colClassList.push(className);
}
}
}
}
// give minor styling to all cells in the table, by column property.
// this allows more nuanced control by other css properties to affect
// cells beyond this.
for (let colClass of colClassList)
{
cd.SetCssClassProperties(colClass, {
textAlign: "center",
padding: "2px",
});
}
// do column groupings
let columnGroupLeftRightList = [
["Pro", "Pro" ],
["DateTimeUtc", "DateTimeUtc" ],
["DateTimeLocal", "DateTimeLocal"],
["RegCall", "RegLng" ],
["BtGpsValid", "BtMPH" ],
["EbtGpsValid", "EbtAltM" ],
["HiResReference","HiResLng" ],
["Lat", "WinFreqDrift" ],
["AltM", "DistKm" ],
["AltFt", "DistMi" ],
];
// do column groupings for dynamic columns
let GetSlotColumnBorderList = (startsWith) => {
let columnBorderList = []
// find headers related to this column set
let slotHeaderList = td.GetHeaderList().filter(str => str.startsWith(startsWith));
// style the encoded message so it has a border on each side
let colEncMsgRaw = `${startsWith}.EncMsg`;
let colEncMsg = utl.StrToCssClassName(colEncMsgRaw);
columnBorderList.push([colEncMsg, colEncMsg]);
// remove the encoded message column to find remaining
slotHeaderList = slotHeaderList.filter(col => col != colEncMsgRaw);
// style remaining columns so a vertical border leftmost and rightmost
if (slotHeaderList.length)
{
let colStartRaw = slotHeaderList[0];
let colEndRaw = slotHeaderList[slotHeaderList.length - 1];
let colStart = utl.StrToCssClassName(colStartRaw);
let colEnd = utl.StrToCssClassName(colEndRaw);
columnBorderList.push([colStart, colEnd]);
}
return columnBorderList;
};
for (let slot = 0; slot < 5; ++slot)
{
columnGroupLeftRightList.push(...GetSlotColumnBorderList(`slot${slot}.ud`));
columnGroupLeftRightList.push(...GetSlotColumnBorderList(`slot${slot}.vd`));
}
// apply style to groupings
for (let columnGroupLeftRight of columnGroupLeftRightList)
{
let [colLeft, colRight] = columnGroupLeftRight;
cd.SetCssClassProperties(`${colLeft}_col`, {
borderLeft: "1px solid black",
borderCollapse: "collapse",
});
cd.SetCssClassProperties(`${colRight}_col`, {
borderRight: "1px solid black",
borderCollapse: "collapse",
});
}
}
ModifyWebpageFormattingExtendedUserAndVendorDefined(table)
{
let cd = new CSSDynamic();
for (let slot = 0; slot < 5; ++slot)
{
cd.SetCssClassProperties(utl.StrToCssClassName(`slot${slot}.ud.EncMsg_data`), {
textAlign: "left",
});
cd.SetCssClassProperties(utl.StrToCssClassName(`slot${slot}.vd.EncMsg_data`), {
textAlign: "left",
});
}
}
StackColumnNameByDot(table)
{
let thList = Array.from(table.querySelectorAll('th'));
for (let th of thList)
{
let partList = th.innerHTML.split('.');
if (partList.length >= 2)
{
let prefixList = partList;
let lastPart = prefixList.pop();
let prefixStr = prefixList.join(".");
let stackedVal = `${prefixStr}
${lastPart}`;
th.innerHTML = stackedVal;
}
}
}
ApplyHeaderDisplayAliases(table)
{
let aliasMap = new Map([
["HiResReference", "HiResRef"],
["HiResLatitudeIdx", "HiResLatIdx"],
["HiResLongitudeIdx", "HiResLngIdx"],
["EbtLatitudeIdx", "EbtLatIdx"],
["EbtLongitudeIdx", "EbtLngIdx"],
["GpsTryLockSeconds", "GpsTryLockSecs"],
]);
let thList = Array.from(table.querySelectorAll("th"));
for (let th of thList)
{
let rawHeader = th.textContent?.trim?.();
if (!aliasMap.has(rawHeader))
{
continue;
}
th.textContent = aliasMap.get(rawHeader);
}
}
MakeUI()
{
let ui = document.createElement("div");
let dtc = this.#MakeDataTableControls();
let links = this.#MakeDownloadAndCopyLinks();
// assemble
ui.appendChild(dtc);
ui.appendChild(links);
return ui;
}
#UpdateDownloadAndCopyLinks()
{
let FilenamePrefix = () => {
return `${this.band}_${this.channelPadded}_${this.callsign}_${this.gte}_${this.lte}`;
};
this.linkCsv.onclick = e => {
utl.DownloadCsv(utl.MakeFilename(`${FilenamePrefix()}.csv`), utl.TableToCsv(this.table));
};
let kmlFilenameWithAlt = `${FilenamePrefix()}_with_altitude.kml`;
this.linkKmlWithAltitude.onclick = e => {
utl.DownloadKml(utl.MakeFilename(kmlFilenameWithAlt), this.#ToKml(this.table, kmlFilenameWithAlt, true));
};
let kmlFilenameNoAlt = `${FilenamePrefix()}_no_altitude.kml`;
this.linkKmlNoAltitude.onclick = e => {
utl.DownloadKml(utl.MakeFilename(kmlFilenameNoAlt), this.#ToKml(this.table, kmlFilenameNoAlt));
};
this.linkCopyToClipboard.onclick = e => {
utl.CopyToClipboard(this.table);
};
}
#MakeDownloadAndCopyLinks()
{
let container = document.createElement('div');
this.linkCsv = document.createElement('span');
this.linkCsv.innerHTML = "csv";
this.linkKmlWithAltitude = document.createElement('span');
this.linkKmlWithAltitude.innerHTML = "kml w/ altitude";
this.linkKmlNoAltitude = document.createElement('span');
this.linkKmlNoAltitude.innerHTML = "kml no altitude";
this.linkCopyToClipboard = document.createElement('span');
this.linkCopyToClipboard.innerHTML = "(copy to clipboard)";
// style
let domList = [
this.linkCsv,
this.linkKmlWithAltitude,
this.linkKmlNoAltitude,
this.linkCopyToClipboard,
];
for (let dom of domList)
{
dom.style.cursor = "pointer";
dom.style.color = "blue";
dom.style.textDecoration = "underline";
}
// pack
// (download csv or kml w/ altitude or kml no altitude) or (copy to clipboard)
container.appendChild(document.createTextNode('(download '));
container.appendChild(this.linkCsv);
container.appendChild(document.createTextNode(' or '));
container.appendChild(this.linkKmlWithAltitude);
container.appendChild(document.createTextNode(' or '));
container.appendChild(this.linkKmlNoAltitude);
container.appendChild(document.createTextNode(') or '));
container.appendChild(this.linkCopyToClipboard);
return container;
}
#ToKml(table, filename, withAlt)
{
let retVal = "";
retVal += `