/* 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 += ` ${filename} Your Flight `; if (this.td.Length() >= 1) { let coordsLast = null; let outputLineNum = 0; this.td.ForEach(row => { let grid = this.td.Get(row, "Grid"); if (grid) { ++outputLineNum; // cells are already linkified, etc, so extract plain text grid = grid.match(/>(.*) ${dtLocal} ${dtLocal} #yellowLineGreenPoly 1 1 ${altType} `; retVal += ` ` + coordsLast; retVal += ` ` + coords; retVal += ` `; } coordsLast = coords; } }); } // add in north and south pole, and finish xml retVal += ` North Pole North Pole 0,90,0 South Pole South Pole 0,-90,0 `; retVal += "\n"; return retVal; } #MakeDataTableControls() { let NotifyVisibilityChanged = () => { this.Emit("DATA_TABLE_VISIBILITY_CHANGED"); }; let resetToDefaultFnList = []; // Helper function let FnClassHideShow = (colList, show) => { let cd = new CSSDynamic(); let display = "none"; if (show) { display = "table-cell"; } for (let col of colList) { cd.SetCssClassProperties(utl.StrToCssClassName(`${col}_col`), { display: display, }); } }; this.fnClassHideShow = FnClassHideShow; let FnClassPreviewHover = (colList, show) => { if (!this.table) { return; } for (let col of colList) { let cssHdr = utl.StrToCssClassName(`${col}_hdr`); let cssData = utl.StrToCssClassName(`${col}_data`); for (let dom of this.table.querySelectorAll(`.${cssData}`)) { dom.style.backgroundColor = show ? "rgb(215, 237, 255)" : ""; dom.style.color = show ? "black" : ""; } for (let dom of this.table.querySelectorAll(`.${cssHdr}`)) { dom.style.backgroundColor = show ? "rgb(215, 237, 255)" : ""; dom.style.color = show ? "black" : ""; } } }; let WireHoverPreview = (labelDom, colListOrGetter) => { let getColList = () => { if (typeof colListOrGetter == "function") { return colListOrGetter() ?? []; } return colListOrGetter ?? []; }; labelDom.addEventListener("mouseenter", () => { labelDom.style.backgroundColor = "rgb(215, 237, 255)"; FnClassPreviewHover(getColList(), true); }); labelDom.addEventListener("mouseleave", () => { labelDom.style.backgroundColor = ""; FnClassPreviewHover(getColList(), false); }); }; let MakeCheckboxControl = (name, specList) => { let storageKey = `checkbox.${name}`; let storedVal = localStorage.getItem(storageKey); let storedMap = new Map(); if (storedVal != null) { try { let obj = JSON.parse(storedVal); for (let [col, checked] of Object.entries(obj)) { storedMap.set(col, !!checked); } } catch { } } let update = () => { let obj = {}; for (const spec of specList) { FnClassHideShow([spec.col], spec.input.checked); obj[spec.col] = !!spec.input.checked; } localStorage.setItem(storageKey, JSON.stringify(obj)); NotifyVisibilityChanged(); }; let MakeLabelForSpec = (spec) => { let label = document.createElement("label"); label.style.whiteSpace = "nowrap"; label.appendChild(spec.input); label.appendChild(document.createTextNode(` ${spec.label}`)); WireHoverPreview(label, [spec.col]); return label; }; let MakeContainer = (filterFn) => { let container = document.createElement("span"); let specListFiltered = specList.filter(filterFn); for (let idx = 0; idx < specListFiltered.length; ++idx) { let spec = specListFiltered[idx]; let label = MakeLabelForSpec(spec); container.appendChild(label); if (idx != specListFiltered.length - 1) { container.appendChild(document.createTextNode(" ")); } } return container; }; for (let idx = 0; idx < specList.length; ++idx) { let spec = specList[idx]; let input = document.createElement("input"); input.type = "checkbox"; input.checked = storedMap.has(spec.col) ? storedMap.get(spec.col) : !!spec.checked; input.addEventListener("change", update); spec.input = input; } update(); resetToDefaultFnList.push(() => { for (const spec of specList) { spec.input.checked = !!spec.checked; } update(); }); return { commonUi: MakeContainer(spec => !!spec.checked), lessCommonUi: MakeContainer(spec => !spec.checked), makeCustomUiFromLabels: (labelGroupList) => { let outer = document.createElement("div"); outer.style.display = "inline-grid"; outer.style.gridTemplateColumns = `repeat(${labelGroupList.length}, auto)`; outer.style.columnGap = "16px"; outer.style.alignItems = "start"; outer.style.justifyContent = "start"; outer.style.width = "max-content"; for (let labelList of labelGroupList) { let col = document.createElement("div"); col.style.display = "flex"; col.style.flexDirection = "column"; col.style.gap = "2px"; for (let labelText of labelList) { let spec = specList.find(spec => spec.label == labelText); if (spec) { col.appendChild(MakeLabelForSpec(spec)); } } outer.appendChild(col); } return outer; }, }; }; let MakeSingleCheckbox = (storageKey, labelText, defaultChecked, onChange) => { let container = document.createElement("span"); let label = document.createElement("label"); label.style.whiteSpace = "nowrap"; let input = document.createElement("input"); input.type = "checkbox"; input.checked = WsprSearchUiDataTableVisibility.GetStoredToggle(storageKey, defaultChecked); input.addEventListener("change", () => { localStorage.setItem(storageKey, input.checked ? "yes" : "no"); onChange(input.checked); NotifyVisibilityChanged(); }); label.appendChild(input); label.appendChild(document.createTextNode(` ${labelText}`)); if (typeof onChange.colListGetter == "function") { WireHoverPreview(label, onChange.colListGetter); } container.appendChild(label); onChange(input.checked); resetToDefaultFnList.push(() => { input.checked = defaultChecked; localStorage.setItem(storageKey, input.checked ? "yes" : "no"); onChange(input.checked); NotifyVisibilityChanged(); }); return container; }; let GetUserVendorDefinedColList = (type, raw) => { let colList = []; for (let slot = 0; slot < 5; ++slot) { let prefix = `slot${slot}.${type}.`; for (let col of this.tdSource?.GetHeaderList?.() ?? []) { if (!col.startsWith(prefix)) { continue; } let isRaw = col == `${prefix}EncMsg`; if ((raw && isRaw) || (!raw && !isRaw)) { colList.push(col); } } } return colList; }; let udDecodedOnChange = checked => { FnClassHideShow(GetUserVendorDefinedColList("ud", false), checked); }; udDecodedOnChange.colListGetter = () => GetUserVendorDefinedColList("ud", false); let udDecodedControl = MakeSingleCheckbox("udDecodedVisible", "Decoded", true, udDecodedOnChange); let udRawOnChange = checked => { FnClassHideShow(GetUserVendorDefinedColList("ud", true), checked); }; udRawOnChange.colListGetter = () => GetUserVendorDefinedColList("ud", true); let udRawControl = MakeSingleCheckbox("udRawVisible", "Raw", true, udRawOnChange); let vdDecodedOnChange = checked => { FnClassHideShow(GetUserVendorDefinedColList("vd", false), checked); }; vdDecodedOnChange.colListGetter = () => GetUserVendorDefinedColList("vd", false); let vdDecodedControl = MakeSingleCheckbox("vdDecodedVisible", "Decoded", true, vdDecodedOnChange); let vdRawOnChange = checked => { FnClassHideShow(GetUserVendorDefinedColList("vd", true), checked); }; vdRawOnChange.colListGetter = () => GetUserVendorDefinedColList("vd", true); let vdRawControl = MakeSingleCheckbox("vdRawVisible", "Raw", true, vdRawOnChange); let MakeInlineControlGroup = (uiList) => { let container = document.createElement("span"); let first = true; for (let ui of uiList) { if (!ui) { continue; } if (!first) { container.appendChild(document.createTextNode(" ")); } container.appendChild(ui); first = false; } return container; }; let dateTimeSpecList = WsprSearchUiDataTableVisibility.GetDateTimeSpecList(); let dateTimeControl = MakeCheckboxControl("dateTimeVisible", [ { label: "DateTimeUtc", ...dateTimeSpecList[0] }, { label: "DateTimeLocal", ...dateTimeSpecList[1] }, ]); let basicTelemetrySpecList = WsprSearchUiDataTableVisibility.GetCheckboxSpecList("basicTelemetryVisible"); let expandedBasicTelemetrySpecList = WsprSearchUiDataTableVisibility.GetCheckboxSpecList("expandedBasicTelemetryVisible"); let highResLocationSpecList = WsprSearchUiDataTableVisibility.GetCheckboxSpecList("highResLocationVisible"); let resolvedSpecList = WsprSearchUiDataTableVisibility.GetCheckboxSpecList("resolvedVisible"); let regularType1SpecList = WsprSearchUiDataTableVisibility.GetCheckboxSpecList("regularType1Visible"); let heartbeatSpecList = WsprSearchUiDataTableVisibility.GetCheckboxSpecList("heartbeatVisible"); // Handle Basic Telemetry column hide/show let basicTelemetryControl = MakeCheckboxControl("basicTelemetryVisible", [ { label: "GpsValid", ...basicTelemetrySpecList[0] }, { label: "Grid56", ...basicTelemetrySpecList[1] }, { label: "Grid6", ...basicTelemetrySpecList[2] }, { label: "Lat", ...basicTelemetrySpecList[3] }, { label: "Lng", ...basicTelemetrySpecList[4] }, { label: "TempC", ...basicTelemetrySpecList[5] }, { label: "TempF", ...basicTelemetrySpecList[6] }, { label: "Voltage", ...basicTelemetrySpecList[7] }, { label: "AltM", ...basicTelemetrySpecList[8] }, { label: "AltFt", ...basicTelemetrySpecList[9] }, { label: "KPH", ...basicTelemetrySpecList[10] }, { label: "MPH", ...basicTelemetrySpecList[11] }, ]); let basicTelemetryLessCommonUi = basicTelemetryControl.makeCustomUiFromLabels([ ["Grid6", "Grid56", "Lat", "Lng", "Voltage"], ["TempF", "AltFt", "MPH"], ["TempC", "AltM", "KPH"], ]); let expandedBasicTelemetryControl = MakeCheckboxControl("expandedBasicTelemetryVisible", [ { label: "GpsValid", ...expandedBasicTelemetrySpecList[0] }, { label: "Voltage", ...expandedBasicTelemetrySpecList[1] }, { label: "Lat", ...expandedBasicTelemetrySpecList[2] }, { label: "Lng", ...expandedBasicTelemetrySpecList[3] }, { label: "LatIdx", ...expandedBasicTelemetrySpecList[4] }, { label: "LngIdx", ...expandedBasicTelemetrySpecList[5] }, { label: "TempF", ...expandedBasicTelemetrySpecList[6] }, { label: "AltFt", ...expandedBasicTelemetrySpecList[7] }, { label: "TempC", ...expandedBasicTelemetrySpecList[8] }, { label: "AltM", ...expandedBasicTelemetrySpecList[9] }, ]); let expandedBasicTelemetryLessCommonUi = expandedBasicTelemetryControl.makeCustomUiFromLabels([ ["Lat", "Lng", "LatIdx", "LngIdx"], ["TempF", "AltFt"], ["TempC", "AltM"], ]); let highResLocationControl = MakeCheckboxControl("highResLocationVisible", [ { label: "Lat", ...highResLocationSpecList[0] }, { label: "Lng", ...highResLocationSpecList[1] }, { label: "Ref", ...highResLocationSpecList[2] }, { label: "LatIdx", ...highResLocationSpecList[3] }, { label: "LngIdx", ...highResLocationSpecList[4] }, ]); let resolvedControl = MakeCheckboxControl("resolvedVisible", [ { label: "Lat", ...resolvedSpecList[0] }, { label: "Lng", ...resolvedSpecList[1] }, { label: "TempF", ...resolvedSpecList[2] }, { label: "TempC", ...resolvedSpecList[3] }, { label: "Voltage", ...resolvedSpecList[4] }, { label: "AltFt", ...resolvedSpecList[5] }, { label: "AltM", ...resolvedSpecList[6] }, { label: "KPH", ...resolvedSpecList[7] }, { label: "MPH", ...resolvedSpecList[8] }, { label: "AltChgFpm", ...resolvedSpecList[9] }, { label: "GpsMPH", ...resolvedSpecList[10] }, { label: "DistMi", ...resolvedSpecList[11] }, { label: "AltChgMpm", ...resolvedSpecList[12] }, { label: "GpsKPH", ...resolvedSpecList[13] }, { label: "DistKm", ...resolvedSpecList[14] }, { label: "SolAngle", ...resolvedSpecList[15] }, { label: "RxStationCount", ...resolvedSpecList[16] }, { label: "WinFreqDrift", ...resolvedSpecList[17] }, ]); let resolvedCommonUi = resolvedControl.makeCustomUiFromLabels([ ["Lat", "Lng", "Voltage"], ["TempF", "AltFt", "AltChgFpm"], ["MPH", "GpsMPH", "DistMi"], ["TempC", "AltM", "AltChgMpm"], ["KPH", "GpsKPH", "DistKm"], ["SolAngle", "RxStationCount", "WinFreqDrift"], ]); let regularType1Control = MakeCheckboxControl("regularType1Visible", [ { label: "Call", ...regularType1SpecList[0] }, { label: "Grid", ...regularType1SpecList[1] }, { label: "Power", ...regularType1SpecList[2] }, { label: "Lat", ...regularType1SpecList[3] }, { label: "Lng", ...regularType1SpecList[4] }, ]); // Handle Heartbeat column hide/show let heartbeatControl = MakeCheckboxControl("heartbeatVisible", [ { label: "UptimeMinutes", ...heartbeatSpecList[0] }, { label: "GpsLockType", ...heartbeatSpecList[1] }, { label: "GpsTryLockSecs", ...heartbeatSpecList[2] }, { label: "GpsSatsInViewCount", ...heartbeatSpecList[3] }, { label: "TxFreqHzIdx", ...heartbeatSpecList[4] }, { label: "TxFreqMhz", ...heartbeatSpecList[5] }, ]); // Handle Pro column hide/show let proOnChange = checked => { this.showProColumn = checked; FnClassHideShow(["Pro"], this.showProColumn); NotifyVisibilityChanged(); }; proOnChange.colListGetter = () => ["Pro"]; let proControl = MakeSingleCheckbox("proVisible", "Pro", true, proOnChange); // text to show let summary = document.createElement('summary'); summary.innerHTML = "Data Table Controls (click to open/close)"; let applyDefaultColumnVisibility = () => { for (let fn of resetToDefaultFnList) { fn(); } }; let btnResetDefaults = document.createElement("button"); btnResetDefaults.type = "button"; btnResetDefaults.textContent = "Default Column Visibility"; btnResetDefaults.style.marginBottom = "8px"; btnResetDefaults.addEventListener("click", () => { applyDefaultColumnVisibility(); }); let table = document.createElement("table"); let thead = document.createElement("thead"); let trHead = document.createElement("tr"); let thView = document.createElement("th"); let thCommon = document.createElement("th"); let thLessCommon = document.createElement("th"); thView.textContent = "Source"; thCommon.textContent = "Show Common"; thLessCommon.textContent = "Show Less Common"; thView.style.textAlign = "left"; thCommon.style.textAlign = "left"; thLessCommon.style.textAlign = "left"; thView.style.paddingRight = "10px"; thCommon.style.paddingRight = "10px"; trHead.appendChild(thView); trHead.appendChild(thCommon); trHead.appendChild(thLessCommon); thead.appendChild(trHead); let tbody = document.createElement("tbody"); let rowList = [ ["DateTime", dateTimeControl.commonUi, dateTimeControl.lessCommonUi], ["RegularType1", regularType1Control.commonUi, regularType1Control.lessCommonUi], [`BasicTelemetry`, basicTelemetryControl.commonUi, basicTelemetryLessCommonUi], ["Merged / Synth", resolvedCommonUi, null], [`Heartbeat`, heartbeatControl.commonUi, heartbeatControl.lessCommonUi], [`ExpandedBasicTelemetry`, expandedBasicTelemetryControl.commonUi, expandedBasicTelemetryLessCommonUi], [`HighResLocation`, highResLocationControl.commonUi, highResLocationControl.lessCommonUi], ["UserDefined", MakeInlineControlGroup([udRawControl, udDecodedControl]), null], ["VendorDefined", MakeInlineControlGroup([vdRawControl, vdDecodedControl]), null], ["Pro", proControl, null], ]; for (const [labelHtml, commonUi, lessCommonUi] of rowList) { let tr = document.createElement("tr"); let tdLabel = document.createElement("td"); tdLabel.innerHTML = labelHtml; tdLabel.style.paddingRight = "10px"; let tdCommon = document.createElement("td"); tdCommon.style.paddingRight = "10px"; if (commonUi) { tdCommon.appendChild(commonUi); } let tdLessCommon = document.createElement("td"); if (lessCommonUi) { tdLessCommon.appendChild(lessCommonUi); } tr.appendChild(tdLabel); tr.appendChild(tdCommon); tr.appendChild(tdLessCommon); tbody.appendChild(tr); } table.appendChild(thead); table.appendChild(tbody); // make details container let details = document.createElement('details'); details.open = this.#GetDataTableControlsOpenState(); details.addEventListener("toggle", () => { this.#SetDataTableControlsOpenState(details.open); }); this.#RunUiUpgradeIfNeeded({ details, applyDefaultColumnVisibility, }); // assemble details.appendChild(summary); details.appendChild(btnResetDefaults); details.appendChild(table); details.appendChild(document.createElement('br')); return details; } #GetDataTableControlsOpenState() { let cookieName = `${WsprSearchUiDataTableController.DETAILS_COOKIE_NAME}=`; let cookiePartList = document.cookie.split(";"); for (let cookiePart of cookiePartList) { cookiePart = cookiePart.trim(); if (cookiePart.startsWith(cookieName)) { return cookiePart.substring(cookieName.length) == "1"; } } return true; } #SetDataTableControlsOpenState(open) { let maxAgeSeconds = 60 * 60 * 24 * 365; let val = open ? "1" : "0"; document.cookie = `${WsprSearchUiDataTableController.DETAILS_COOKIE_NAME}=${val}; path=/; max-age=${maxAgeSeconds}; samesite=lax`; } #GetStoredUiUpgradeVersion() { return Number(localStorage.getItem(WsprSearchUiDataTableController.UI_UPGRADE_VERSION_KEY) ?? 0); } #SetStoredUiUpgradeVersion(version) { localStorage.setItem(WsprSearchUiDataTableController.UI_UPGRADE_VERSION_KEY, String(version)); } #NeedsUiUpgrade() { return this.#GetStoredUiUpgradeVersion() < WsprSearchUiDataTableController.UI_UPGRADE_VERSION; } #RunUiUpgradeStep(version, { details, applyDefaultColumnVisibility }) { switch (version) { case 1: details.open = true; this.#SetDataTableControlsOpenState(true); applyDefaultColumnVisibility(); break; case 2: this.Emit("SHOW_MAP_CONTROL_HELP"); break; } } #RunUiUpgradeIfNeeded(args) { if (!this.#NeedsUiUpgrade()) { return; } let versionStored = this.#GetStoredUiUpgradeVersion(); for (let version = versionStored + 1; version <= WsprSearchUiDataTableController.UI_UPGRADE_VERSION; ++version) { this.#RunUiUpgradeStep(version, args); this.#SetStoredUiUpgradeVersion(version); } } #RenderDataTable() { if (this.tdSource == null) { return; } // full re-render (controls + table) this.cfg.container.innerHTML = ""; this.proRowControllerList = []; // copy data so it can be modified without affecting other holders this.td = this.tdSource.Clone(); // jazz up data content this.ModifyTableContentsForDisplay(this.td); // build the shell immediately, then parse/append body rows incrementally this.table = this.#MakeIncrementalTableShell(this.td.GetDataTable()); // jazz up webpage presentation this.ModifyWebpageFormatting(this.td, this.table); // save width with dotted column headers (ie slotx.field) this.StackColumnNameByDot(this.table); this.ApplyHeaderDisplayAliases(this.table); this.#ApplyUserVendorDefinedVisibility(); // replace with new this.cfg.container.appendChild(this.ui); this.cfg.container.appendChild(this.table); this.#AppendTableRowsIncrementally(this.renderTableToken); } #MakeIncrementalTableShell(dataTable) { if (!dataTable?.length) { return utl.MakeTable([[]]); } return utl.MakeTable([dataTable[0]]); } #AppendTableRowsIncrementally(token) { let dataTable = this.td.GetDataTable(); let tbody = this.table?.tBodies?.[0]; if (!tbody || dataTable.length <= 1) { this.#WireProRowDialogs(); this.#TryHandlePendingJumpToData(); return; } let rowHeader = dataTable[0]; let classAttrByIdx = rowHeader.map(colVal => { let colClass = ""; let dataClass = ""; try { colClass = utl.StrToCssClassName(`${colVal}_col`); dataClass = utl.StrToCssClassName(`${colVal}_data`); } catch (e) { } let classList = [colClass, dataClass].filter(str => str != ""); return classList.length ? ` class="${classList.join(" ")}"` : ""; }); let EscapeHtml = (val) => { return String(val) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); }; let rowIdx = 1; let CHUNK_ROW_COUNT = 200; let appendChunk = () => { if (token != this.renderTableToken || tbody.isConnected == false) { return; } let html = ""; let trListAdded = []; let rowIdxStart = rowIdx; let count = 0; while (rowIdx < dataTable.length && count < CHUNK_ROW_COUNT) { let row = dataTable[rowIdx]; let hasElement = false; for (let idx = 0; idx < row.length; ++idx) { if (row[idx] instanceof Element) { hasElement = true; break; } } if (hasElement) { if (html != "") { tbody.insertAdjacentHTML("beforeend", html); let trListNew = Array.from(tbody.rows).slice(-(rowIdx - rowIdxStart)); trListAdded.push(...trListNew); html = ""; rowIdxStart = rowIdx; } let tr = document.createElement("tr"); for (let idx = 0; idx < row.length; ++idx) { let td = document.createElement("td"); let colClass = utl.StrToCssClassName(`${rowHeader[idx]}_col`); let dataClass = utl.StrToCssClassName(`${rowHeader[idx]}_data`); if (colClass != "") { td.classList.add(colClass); } if (dataClass != "") { td.classList.add(dataClass); } let val = row[idx]; if (val instanceof Element) { td.appendChild(val); } else if (typeof val == "string" && val.indexOf("<") != -1) { td.innerHTML = val; } else { td.textContent = val ?? ""; } tr.appendChild(td); } tbody.appendChild(tr); trListAdded.push(tr); ++rowIdx; ++count; rowIdxStart = rowIdx; continue; } html += ""; for (let idx = 0; idx < row.length; ++idx) { let colVal = row[idx]; let cellHtml = ""; if (typeof colVal == "string" && colVal.indexOf("<") != -1) { cellHtml = colVal; } else { cellHtml = EscapeHtml(colVal ?? ""); } html += `${cellHtml}`; } html += ""; ++rowIdx; ++count; } if (html != "") { let rowCountHtml = rowIdx - rowIdxStart; tbody.insertAdjacentHTML("beforeend", html); let trListNew = Array.from(tbody.rows).slice(-rowCountHtml); trListAdded.push(...trListNew); } this.#WireProRowDialogsInRows(trListAdded, rowIdx - trListAdded.length - 1); this.#TryHandlePendingJumpToData(); if (rowIdx < dataTable.length) { if (window.requestIdleCallback) { window.requestIdleCallback(() => appendChunk(), { timeout: 100 }); } else { window.setTimeout(() => { window.requestAnimationFrame(() => appendChunk()); }, 0); } } }; appendChunk(); } #ApplyUserVendorDefinedVisibility() { if (!this.fnClassHideShow || !this.tdSource) { return; } let applyPrefixVisibility = (type, showDecoded, showRaw) => { let decodedColList = []; let rawColList = []; for (let slot = 0; slot < 5; ++slot) { let prefix = `slot${slot}.${type}.`; for (let col of this.tdSource.GetHeaderList()) { if (!col.startsWith(prefix)) { continue; } if (col == `${prefix}EncMsg`) { rawColList.push(col); } else { decodedColList.push(col); } } } this.fnClassHideShow(decodedColList, showDecoded); this.fnClassHideShow(rawColList, showRaw); }; applyPrefixVisibility( "ud", WsprSearchUiDataTableVisibility.GetStoredToggle("udDecodedVisible", true), WsprSearchUiDataTableVisibility.GetStoredToggle("udRawVisible", false), ); applyPrefixVisibility( "vd", WsprSearchUiDataTableVisibility.GetStoredToggle("vdDecodedVisible", true), WsprSearchUiDataTableVisibility.GetStoredToggle("vdRawVisible", false), ); } #WireProRowDialogs() { let tbody = this.table.tBodies[0]; if (!tbody) { return; } let trList = Array.from(tbody.rows); this.#WireProRowDialogsInRows(trList, 0); } #WireProRowDialogsInRows(trList, rowIdxOffset = 0) { for (let rowIdx = 0; rowIdx < trList.length; ++rowIdx) { let tr = trList[rowIdx]; tr.dataset.rowIdx = `${rowIdxOffset + rowIdx}`; let emoji = tr.querySelector(".proInsightEmoji"); if (!emoji) { continue; } let controller = null; emoji.addEventListener("click", () => { if (controller == null) { controller = new WsprSearchUiDataTableRowProController({ data: { dt: this.td, rowIdx: rowIdxOffset + rowIdx, wsprSearch: this.wsprSearch, band: this.band, channel: this.channel, }, }); this.proRowControllerList.push(controller); document.body.appendChild(controller.GetUI()); } controller.Show(); }); } } #TryHandlePendingJumpToData() { if (!this.pendingJumpToDataTs || !this.table || !this.td) { return; } let targetRowIdx = -1; this.td.ForEach((row, idx) => { if (targetRowIdx == -1 && this.td.Get(row, "DateTimeLocal") == this.pendingJumpToDataTs) { targetRowIdx = idx; } }); if (targetRowIdx == -1) { return; } let tr = this.table.querySelector(`tbody tr[data-row-idx="${targetRowIdx}"]`); if (!tr) { return; } let headerHeight = this.table.tHead?.offsetHeight ?? 0; let top = window.scrollY + tr.getBoundingClientRect().top - headerHeight - 2; window.scrollTo({ top: Math.max(0, top), behavior: "auto", }); this.pendingJumpToDataTs = null; } }