/* 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 { CollapsableTitleBox, DialogBox } from './DomWidgets.js'; import { WSPR } from '/js/WSPR.js'; export class WsprSearchUiDataTableRowProController { constructor(cfg) { this.cfg = cfg || {}; this.data = this.cfg.data || {}; this.dialog = new DialogBox(); this.dialog.SetTitleBar("Pro Row Insight"); this.hasShown = false; this.colWidthList = ["20%", "12%", "6%", "37%", "25%"]; let content = this.dialog.GetContentContainer(); content.style.width = "1110px"; content.style.minWidth = "1110px"; content.style.minHeight = "520px"; content.style.maxHeight = "calc(100vh - 120px)"; this.body = document.createElement("div"); this.body.style.padding = "8px"; this.body.style.display = "flex"; this.body.style.flexDirection = "column"; this.body.style.gap = "8px"; content.appendChild(this.body); this.#RefreshBody(); } GetUI() { return this.dialog.GetUI(); } Show() { this.#RefreshBody(); if (!this.hasShown) { let ui = this.dialog.GetUI(); ui.style.left = "80px"; ui.style.top = "80px"; this.hasShown = true; } this.dialog.Show(); } #RefreshBody() { this.body.innerHTML = ""; this.rxRecord__nodeSet = new Map(); this.rxRecordHighlightNodeSet = new Set(); let dt = this.data.dt; let rowIdx = this.data.rowIdx; let dtUtc = ""; let dtLocal = ""; if (dt && rowIdx != undefined) { dtUtc = dt.Get(rowIdx, "DateTimeUtc") || ""; dtLocal = dt.Get(rowIdx, "DateTimeLocal") || ""; } this.dialog.SetTitleBar(`Pro Row Insight - DateTimeUtc: ${dtUtc} | DateTimeLocal: ${dtLocal}`); let slotMsgListList = this.#GetSlotMsgListList(); for (let slot = 0; slot < 5; ++slot) { this.body.appendChild(this.#MakeSlotSection(slot, slotMsgListList[slot] || [])); } } #GetSlotMsgListList() { let slotMsgListList = [[], [], [], [], []]; let dt = this.data.dt; let rowIdx = this.data.rowIdx; let wsprSearch = this.data.wsprSearch; let rowMeta = dt && rowIdx != undefined ? dt.GetRowMetaData(rowIdx) : null; let time = rowMeta?.time; // Preferred source: full per-window slot message lists from WsprSearch. let windowData = wsprSearch?.time__windowData?.get?.(time); if (windowData?.slotDataList) { for (let slot = 0; slot < 5; ++slot) { slotMsgListList[slot] = windowData.slotDataList[slot]?.msgList || []; } return slotMsgListList; } // Fallback source: selected single-candidate-per-slot snapshot. let slotMsgList = rowMeta?.slotMsgList || []; for (let slot = 0; slot < 5; ++slot) { let msg = slotMsgList[slot]; if (msg) { slotMsgListList[slot] = [msg]; } } return slotMsgListList; } #MakeSlotSection(slotIdx, msgList) { let section = new CollapsableTitleBox(); section.SetTitle(`Slot ${slotIdx} (click to open/collapse)`); section.SetMinWidth("0px"); section.Show(); let sectionUi = section.GetUI(); let sectionBody = section.GetContentContainer(); let table = document.createElement("table"); table.style.borderCollapse = "collapse"; table.style.backgroundColor = "white"; table.style.width = "100%"; table.style.tableLayout = "fixed"; this.#AppendColGroup(table); let thead = document.createElement("thead"); let htr = document.createElement("tr"); this.#AppendCell(htr, "Type", true); this.#AppendCell(htr, "Message", true); this.#AppendCell(htr, "Count", true); this.#AppendCell(htr, "Status", true); this.#AppendCell(htr, "Details", true); thead.appendChild(htr); table.appendChild(thead); let tbody = document.createElement("tbody"); let msgGroupList = this.#GetMsgGroupList(msgList); if (msgGroupList.length == 0) { let tr = document.createElement("tr"); this.#AppendCell(tr, "-", false); this.#AppendCell(tr, "-", false); this.#AppendCell(tr, "0", false); this.#AppendCell(tr, "-", false); this.#SetRowBackground(tr, "#ffecec"); let tdDetails = this.#MakeDetailsCell(); this.#SetDetailsPlaceholder(tdDetails, "No messages in this slot.", false); tr.appendChild(tdDetails); tbody.appendChild(tr); } else { let candidateRowCount = 0; for (const g of msgGroupList) { candidateRowCount += g.isActive ? 1 : 0; } let tdDetails = this.#MakeDetailsCell(); tdDetails.rowSpan = msgGroupList.length; this.#SetDetailsPlaceholder(tdDetails, "Click row to show/hide RX details.", true); let activeRowIdx = -1; let activeTr = null; for (let idx = 0; idx < msgGroupList.length; ++idx) { let g = msgGroupList[idx]; let tr = document.createElement("tr"); tr.style.cursor = "pointer"; let suppressClickForSelection = false; this.#AppendCell(tr, g.msgTypeDisplay, false); this.#AppendCell(tr, g.msgDisplay, false); this.#AppendCell(tr, g.rxCount.toString(), false); this.#AppendCell(tr, g.rejectReason, false); let isSingleCandidateRow = candidateRowCount == 1 && g.isActive; this.#SetRowBackground(tr, isSingleCandidateRow ? "#ecffec" : "#ffecec"); if (idx == 0) { tr.appendChild(tdDetails); } tr.addEventListener("mousedown", (e) => { if (e.detail > 1) { e.preventDefault(); window.getSelection?.()?.removeAllRanges?.(); suppressClickForSelection = false; return; } suppressClickForSelection = this.#SelectionIntersectsNode(tr); }); tr.addEventListener("dblclick", (e) => { e.preventDefault(); }); tr.addEventListener("click", () => { if (suppressClickForSelection || this.#SelectionIntersectsNode(tr)) { suppressClickForSelection = false; return; } suppressClickForSelection = false; if (activeTr) { this.#SetRowActiveStyle(activeTr, tdDetails, false); } if (activeRowIdx == idx) { activeRowIdx = -1; activeTr = null; this.#SetDetailsPlaceholder(tdDetails, "Click row to show/hide RX details.", true); return; } activeRowIdx = idx; activeTr = tr; tdDetails.textContent = ""; tdDetails.appendChild(this.#MakeRxDetailsUi(g.rxRecordList, slotIdx, g)); this.#SetRowActiveStyle(activeTr, tdDetails, true); }); tbody.appendChild(tr); } } table.appendChild(tbody); sectionBody.appendChild(table); return sectionUi; } #AppendCell(tr, text, isHeader) { let td = document.createElement(isHeader ? "th" : "td"); td.textContent = text; td.style.border = "1px solid #999"; td.style.padding = "3px 6px"; td.style.verticalAlign = "top"; td.style.whiteSpace = "normal"; td.style.overflowWrap = "anywhere"; if (isHeader) { td.style.backgroundColor = "#ddd"; td.style.textAlign = "left"; } tr.appendChild(td); } #AppendColGroup(table) { // Keep a consistent width profile across every slot table. let cg = document.createElement("colgroup"); for (const w of this.colWidthList) { let col = document.createElement("col"); col.style.width = w; cg.appendChild(col); } table.appendChild(cg); } #MakeDetailsCell() { let td = document.createElement("td"); td.style.border = "1px solid #999"; td.style.padding = "3px 6px"; td.style.verticalAlign = "top"; td.style.minWidth = "0"; td.style.whiteSpace = "pre-wrap"; td.style.fontFamily = "monospace"; td.style.userSelect = "text"; td.style.cursor = "text"; // Keep details text selectable/copyable without toggling row state. td.addEventListener("click", (e) => { e.stopPropagation(); }); return td; } #SelectionIntersectsNode(node) { let selection = window.getSelection?.(); if (!selection || selection.rangeCount == 0 || selection.isCollapsed) { return false; } let range = selection.getRangeAt(0); let commonAncestor = range.commonAncestorContainer; return node.contains(commonAncestor); } #SetDetailsPlaceholder(td, text, useDim) { td.textContent = ""; let span = document.createElement("span"); span.textContent = text; if (useDim) { span.style.color = "#666"; } td.appendChild(span); } #SetRowBackground(tr, color) { for (const cell of tr.cells) { if (cell.tagName == "TD") { cell.style.backgroundColor = color; } } } #SetRowActiveStyle(tr, tdDetails, active) { // Reset row/detail cell active effect only (do not mutate border geometry). for (const cell of tr.cells) { if (cell.tagName == "TD") { cell.style.boxShadow = "none"; } } tdDetails.style.boxShadow = "none"; if (!active) { return; } // Active row: emphasize inward (inset) so layout does not expand outward. for (let i = 0; i < tr.cells.length; ++i) { let cell = tr.cells[i]; if (cell.tagName != "TD") { continue; } let insetTopBottom = "inset 0 2px 0 #444, inset 0 -2px 0 #444"; let insetLeft = i == 0 ? ", inset 2px 0 0 #444" : ""; cell.style.boxShadow = insetTopBottom + insetLeft; } // Keep details visually tied to active row with inward emphasis. tdDetails.style.boxShadow = "inset 0 2px 0 #444, inset 0 -2px 0 #444, inset 2px 0 0 #444, inset -2px 0 0 #444"; } #GetMsgGroupList(msgList) { let keyToGroup = new Map(); for (const msg of msgList) { if (!msg) { continue; } let key = this.#GetMsgKey(msg); if (!keyToGroup.has(key)) { keyToGroup.set(key, { msgTypeDisplay: this.#GetMsgTypeDisplay(msg), msgDisplay: this.#GetMsgDisplay(msg), rejectReason: this.#GetRejectReason(msg), isCandidate: false, isConfirmed: false, isActive: false, msgList: [], rxRecordList: [], }); } let g = keyToGroup.get(key); g.isCandidate = g.isCandidate || (msg.IsCandidate && msg.IsCandidate()); g.isConfirmed = g.isConfirmed || (msg.IsConfirmed && msg.IsConfirmed()); g.isActive = g.isActive || (msg.IsNotRejected && msg.IsNotRejected()); g.msgList.push(msg); if (Array.isArray(msg.rxRecordList)) { g.rxRecordList.push(...msg.rxRecordList); } } let out = Array.from(keyToGroup.values()); for (const g of out) { g.rxCount = g.rxRecordList.length; } out.sort((a, b) => { if (b.rxCount != a.rxCount) { return b.rxCount - a.rxCount; } return a.msgDisplay.localeCompare(b.msgDisplay); }); return out; } #GetMsgKey(msg) { let f = msg.fields || {}; let decodeType = msg.decodeDetails?.type || ""; return JSON.stringify([ msg.type || "", decodeType, f.callsign || "", f.grid4 || "", f.powerDbm || "", ]); } #GetMsgDisplay(msg) { let f = msg.fields || {}; return `${f.callsign || ""} ${f.grid4 || ""} ${f.powerDbm || ""}`.trim(); } #GetMsgTypeDisplay(msg) { let type = msg.type || ""; if (type == "telemetry") { let decodeType = msg.decodeDetails?.type || "?"; if (decodeType == "extended") { let prettyType = msg.decodeDetails?.extended?.prettyType || ""; if (prettyType != "") { return `telemetry/${prettyType}`; } } return `telemetry/${decodeType}`; } if (type == "regular") { return "regular"; } return type || "-"; } #GetRejectReason(msg) { let reason = ""; if (msg.IsConfirmed && msg.IsConfirmed()) { reason = "Confirmed"; } else if (msg.IsCandidate && msg.IsCandidate()) { reason = "Candidate"; } else { let audit = msg.candidateFilterAuditList?.[0]; if (!audit) { reason = "Rejected"; } else if (audit.note) { reason = `${audit.type || "Rejected"}: ${audit.note}`; } else { reason = audit.type || "Rejected"; } } if (this.#IsFingerprintReferenceMsg(msg)) { reason += reason != "" ? " (frequency reference)" : "Frequency reference"; } return reason; } #IsFingerprintReferenceMsg(msg) { let referenceMsg = this.data.wsprSearch ?.time__windowData ?.get?.(this.data.dt?.GetRowMetaData(this.data.rowIdx)?.time) ?.fingerprintingData ?.referenceAudit ?.referenceMsg; return referenceMsg === msg; } #MakeRxDetailsUi(rxRecordList, slotIdx, msgGroup) { let expectedFreqHz = this.#GetExpectedFreqHz(); let rowList = []; for (const rx of rxRecordList) { let freq = ""; let freqSort = Number.NaN; let offset = null; if (rx?.frequency != undefined && rx?.frequency !== "") { let n = Number(rx.frequency); if (Number.isFinite(n)) { freqSort = n; freq = n.toLocaleString("en-US"); if (expectedFreqHz != null) { offset = Math.round(n - expectedFreqHz); } } else { freq = `${rx.frequency}`; } } rowList.push({ rxRecord: rx, station: rx?.rxCallsign ? rx.rxCallsign : "", freq: freq, freqSort: freqSort, offset: offset, }); } rowList.sort((a, b) => { let aOffsetValid = a.offset != null; let bOffsetValid = b.offset != null; if (aOffsetValid && bOffsetValid && a.offset != b.offset) { return b.offset - a.offset; } if (aOffsetValid != bOffsetValid) { return aOffsetValid ? -1 : 1; } let c1 = a.station.localeCompare(b.station); if (c1 != 0) { return c1; } let aFreqValid = Number.isFinite(a.freqSort); let bFreqValid = Number.isFinite(b.freqSort); if (aFreqValid && bFreqValid && a.freqSort != b.freqSort) { return a.freqSort - b.freqSort; } return a.freq.localeCompare(b.freq); }); let hdr = { station: "RX Station", freq: "RX Freq", offset: "+/-LaneHz", }; let wStation = hdr.station.length; let wFreq = hdr.freq.length; let wOffsetValue = 3; for (const r of rowList) { wStation = Math.max(wStation, r.station.length); wFreq = Math.max(wFreq, r.freq.length); if (r.offset != null) { wOffsetValue = Math.max(wOffsetValue, Math.abs(r.offset).toString().length); } } let wOffset = Math.max(hdr.offset.length, 1 + wOffsetValue); let wrapper = document.createElement("div"); wrapper.style.whiteSpace = "pre"; wrapper.style.userSelect = "text"; wrapper.style.fontFamily = "monospace"; wrapper.style.fontSize = "12px"; let makeLine = (station, freq, offset) => `${station.padEnd(wStation)} ${freq.padStart(wFreq)} ${offset.padStart(wOffset)}`; let headerLine = this.#MakeDetailsTextRow(makeLine(hdr.station, hdr.freq, hdr.offset), false); wrapper.appendChild(headerLine); wrapper.appendChild(this.#MakeDetailsTextRow(makeLine("-".repeat(wStation), "-".repeat(wFreq), "-".repeat(wOffset)), false)); if (rowList.length == 0) { wrapper.appendChild(this.#MakeDetailsTextRow("(no receiving stations)", false)); } else { for (const r of rowList) { let line = makeLine(r.station, r.freq, this.#FormatLaneOffset(r.offset, wOffsetValue)); let rowNode = this.#MakeDetailsTextRow(line, true); this.#RegisterRxRecordNode(r.rxRecord, rowNode); rowNode.addEventListener("mouseenter", () => { this.#ApplyFingerprintHover(slotIdx, msgGroup, r.rxRecord); }); rowNode.addEventListener("mouseleave", () => { this.#ClearFingerprintHover(); }); wrapper.appendChild(rowNode); } } return wrapper; } #GetExpectedFreqHz() { let band = this.data.band || ""; let channel = this.data.channel; if (band == "" || channel == "" || channel == undefined) { return null; } let channelDetails = WSPR.GetChannelDetails(band, channel); if (!channelDetails || !Number.isFinite(channelDetails.freq)) { return null; } return channelDetails.freq; } #FormatLaneOffset(offset, width) { if (offset == null) { return ""; } let sign = " "; if (offset > 0) { sign = "+"; } else if (offset < 0) { sign = "-"; } return `${sign}${Math.abs(offset).toString().padStart(width)}`; } #MakeDetailsTextRow(text, interactive) { let row = document.createElement("div"); row.textContent = text; row.style.whiteSpace = "pre"; row.style.cursor = interactive ? "default" : "text"; return row; } #RegisterRxRecordNode(rxRecord, node) { if (!this.rxRecord__nodeSet.has(rxRecord)) { this.rxRecord__nodeSet.set(rxRecord, new Set()); } this.rxRecord__nodeSet.get(rxRecord).add(node); } #ApplyFingerprintHover(slotIdx, msgGroup, rxRecord) { this.#ClearFingerprintHover(); let rxRecordSet = this.#GetFingerprintHoverRxRecordSet(slotIdx, msgGroup, rxRecord); if (rxRecordSet.size == 0) { return; } for (const rxRecordHighlighted of rxRecordSet) { let nodeSet = this.rxRecord__nodeSet.get(rxRecordHighlighted); if (!nodeSet) { continue; } for (const node of nodeSet) { node.style.backgroundColor = "#eefbe7"; this.rxRecordHighlightNodeSet.add(node); } } } #ClearFingerprintHover() { for (const node of this.rxRecordHighlightNodeSet) { node.style.backgroundColor = ""; } this.rxRecordHighlightNodeSet.clear(); } #GetFingerprintHoverRxRecordSet(slotIdx, msgGroup, rxRecord) { let rxRecordSet = new Set(); let referenceAudit = this.data.wsprSearch ?.time__windowData ?.get?.(this.data.dt?.GetRowMetaData(this.data.rowIdx)?.time) ?.fingerprintingData ?.referenceAudit; if (!referenceAudit) { return rxRecordSet; } if (slotIdx == referenceAudit.referenceSlot) { for (const slotAudit of referenceAudit.slotAuditList) { if (!slotAudit || slotAudit.slot == referenceAudit.referenceSlot) { continue; } this.#AddFingerprintMatchesForRecord(rxRecordSet, slotAudit.msgAuditList, rxRecord, "rxRecordListA"); } return rxRecordSet; } let slotAudit = referenceAudit.slotAuditList[slotIdx]; if (!slotAudit) { return rxRecordSet; } let msgSet = new Set(msgGroup.msgList || []); let msgAuditList = (slotAudit.msgAuditList || []).filter(msgAudit => msgSet.has(msgAudit.msg)); this.#AddFingerprintMatchesForRecord(rxRecordSet, msgAuditList, rxRecord, "rxRecordListB"); return rxRecordSet; } #AddFingerprintMatchesForRecord(rxRecordSet, msgAuditList, rxRecord, primaryListName) { for (const msgAudit of msgAuditList || []) { for (const rxCallMatch of msgAudit.rxCallMatchList || []) { let primaryList = rxCallMatch[primaryListName] || []; if (!primaryList.includes(rxRecord)) { continue; } for (const rxRecordA of rxCallMatch.rxRecordListA || []) { rxRecordSet.add(rxRecordA); } for (const rxRecordB of rxCallMatch.rxRecordListB || []) { rxRecordSet.add(rxRecordB); } } } } }