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

791 lines
23 KiB
JavaScript

/*
Copyright (c) 2023-forever Douglas Malnati. All rights reserved.
See the /faq/tos page for details.
(If this generated header is stamped on a file which is a 3rd party file or under a different license or copyright, then ignore this copyright statement and use that file's terms.)
*/
import { 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);
}
}
}
}
}