/* 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 { Base } from './Base.js'; import { StrAccumulator } from '/js/Utl.js'; import { AsyncResourceLoader } from './AsyncResourceLoader.js'; export class WsprSearchUiStatsFilterController extends Base { static urlEchartsScript = `https://fastly.jsdelivr.net/npm/echarts@5.4.2/dist/echarts.min.js`; constructor(cfg) { super(); this.cfg = cfg; this.ok = this.cfg.container && this.cfg.wsprSearch; this.chart = null; this.chartReady = false; this.chartLoadPromise = null; this.renderSankeyToken = 0; if (this.ok) { this.ui = this.MakeUI(); this.cfg.container.appendChild(this.ui); this.EnsureChartReady(); } else { this.Err(`WsprSearchUiStatsFilterController`, `Could not init`); } } OnEvent(evt) { if (this.ok) { switch (evt.type) { case "SEARCH_COMPLETE": this.OnSearchComplete(); break; } } } async OnSearchComplete() { let slotStatsList = this.ComputeSlotStats(); this.RenderText(slotStatsList); this.ScheduleRenderSankey(slotStatsList); } GetFlowSpecList() { return [ { label: "S0 Reg", slot: 0, type: "regular" }, { label: "S0 ET", slot: 0, type: "telemetry" }, { label: "S1 BT", slot: 1, telemetryType: "basic" }, { label: "S1 ET", slot: 1, telemetryType: "extended" }, { label: "S2 ET", slot: 2 }, { label: "S3 ET", slot: 3 }, { label: "S4 ET", slot: 4 }, ]; } MsgMatchesFlowSpec(msg, flowSpec) { if (!flowSpec.type) { if (!flowSpec.telemetryType) { return true; } } if (flowSpec.telemetryType == "basic") { return msg.IsTelemetryBasic(); } if (flowSpec.telemetryType == "extended") { return msg.IsTelemetryExtended(); } return msg.type == flowSpec.type; } ComputeSlotStats() { let flowSpecList = this.GetFlowSpecList(); let slotStatsList = []; for (const flowSpec of flowSpecList) { slotStatsList.push({ label: flowSpec.label, flowSpec: flowSpec, input: 0, rejectedBadTelemetry: 0, rejectedBySpec: 0, rejectedByFingerprinting: 0, finalCandidate: 0, outcomeZeroCandidates: 0, outcomeOneCandidate: 0, outcomeMultiCandidate: 0, }); } this.cfg.wsprSearch.ForEachWindowMsgListList(msgListList => { for (let idx = 0; idx < slotStatsList.length; ++idx) { let s = slotStatsList[idx]; let msgList = msgListList[s.flowSpec.slot]; let msgListFiltered = msgList.filter(msg => this.MsgMatchesFlowSpec(msg, s.flowSpec)); for (const msg of msgListFiltered) { s.input += 1; if (msg.IsCandidate()) { s.finalCandidate += 1; continue; } let rejectType = this.GetRejectType(msg); if (rejectType == "ByBadTelemetry") { s.rejectedBadTelemetry += 1; } else if (rejectType == "BySpec") { s.rejectedBySpec += 1; } else if (rejectType == "ByFingerprinting") { s.rejectedByFingerprinting += 1; } } let candidateCount = 0; for (const msg of msgListFiltered) { if (msg.IsCandidate()) { candidateCount += 1; } } if (candidateCount == 0) { // Keep Sankey math consistent (message units across all stages): // zero-candidate windows contribute zero candidate messages. s.outcomeZeroCandidates += 0; } else if (candidateCount == 1) { s.outcomeOneCandidate += 1; } else { // Multi-candidate windows contribute all remaining candidate messages. s.outcomeMultiCandidate += candidateCount; } } }); for (const s of slotStatsList) { s.afterBadTelemetry = s.input - s.rejectedBadTelemetry; s.afterBySpec = s.afterBadTelemetry - s.rejectedBySpec; s.afterByFingerprinting = s.afterBySpec - s.rejectedByFingerprinting; if (s.afterByFingerprinting < 0) { s.afterByFingerprinting = 0; } } return slotStatsList; } GetRejectType(msg) { let auditList = msg.candidateFilterAuditList || []; if (auditList.length == 0) { return ""; } // By design, first reject in pipeline determines final state. return auditList[0].type || ""; } RenderText(slotStatsList) { let a = new StrAccumulator(); let fmtPct = (num, den) => { let pct = 0; if (den > 0) { pct = Math.round((num / den) * 100); } return `(${pct.toString().padStart(3)}%)`; }; a.A(`Filter Stats (Per Slot)`); a.A(`-----------------------`); for (let slot = 0; slot < slotStatsList.length; ++slot) { let s = slotStatsList[slot]; let valueWidth = Math.max( 1, s.input.toString().length, s.rejectedBadTelemetry.toString().length, s.rejectedBySpec.toString().length, s.rejectedByFingerprinting.toString().length, s.finalCandidate.toString().length, s.outcomeZeroCandidates.toString().length, s.outcomeOneCandidate.toString().length, s.outcomeMultiCandidate.toString().length, ); let fmtVal = (v) => v.toString().padStart(valueWidth); a.A(`${s.label}`); a.A(` Input : ${s.input}`); a.A(` Rejected BadTelemetry : ${fmtVal(s.rejectedBadTelemetry)} ${fmtPct(s.rejectedBadTelemetry, s.input)}`); a.A(` Rejected BySpec : ${fmtVal(s.rejectedBySpec)} ${fmtPct(s.rejectedBySpec, s.input)}`); a.A(` Rejected Fingerprint : ${fmtVal(s.rejectedByFingerprinting)} ${fmtPct(s.rejectedByFingerprinting, s.input)}`); a.A(` Final Candidate : ${fmtVal(s.finalCandidate)} ${fmtPct(s.finalCandidate, s.input)}`); a.A(` Outcome: 0 candidate : ${fmtVal(s.outcomeZeroCandidates)} ${fmtPct(s.outcomeZeroCandidates, s.input)}`); a.A(` Outcome: 1 candidate : ${fmtVal(s.outcomeOneCandidate)} ${fmtPct(s.outcomeOneCandidate, s.input)}`); a.A(` Outcome: 2+ candidates: ${fmtVal(s.outcomeMultiCandidate)} ${fmtPct(s.outcomeMultiCandidate, s.input)}`); } this.ta.value = a.Get(); } async RenderSankey(slotStatsList) { await this.EnsureChartReady(); if (!this.chartReady) { return; } let nodeMap = new Map(); let links = []; let addNode = (name, depth = undefined) => { if (!nodeMap.has(name)) { let node = { name }; if (depth != undefined) { node.depth = depth; } nodeMap.set(name, node); return; } // Keep the earliest stage depth if the node was already created. if (depth != undefined) { let node = nodeMap.get(name); if (node.depth == undefined || depth < node.depth) { node.depth = depth; } } }; let addLink = (source, target, value) => { if (value <= 0) { return; } addNode(source); addNode(target); links.push({ source, target, value }); }; for (let slot = 0; slot < slotStatsList.length; ++slot) { let s = slotStatsList[slot]; let prefix = s.label; let nInput = `${prefix}`; let nAfterBad = `${prefix} After BadTelemetry`; let nAfterSpec = `${prefix} After BySpec`; let nAfterFp = `${prefix} After ByFingerprinting`; let nRejBad = `${prefix} Rejected BadTelemetry`; let nRejSpec = `${prefix} Rejected BySpec`; let nRejFp = `${prefix} Rejected ByFingerprinting`; let nOutcomeZero = `${prefix} Outcome: 0 Candidate`; let nOutcomeOne = `${prefix} Outcome: 1 Candidate`; let nOutcomeMulti = `${prefix} Outcome: 2+ Candidates`; addNode(nInput, 0); addNode(nRejBad, 1); addNode(nAfterBad, 1); addNode(nRejSpec, 2); addNode(nAfterSpec, 2); addNode(nRejFp, 3); addNode(nAfterFp, 3); addNode(nOutcomeZero, 4); addNode(nOutcomeOne, 4); addNode(nOutcomeMulti, 4); addLink(nInput, nRejBad, s.rejectedBadTelemetry); addLink(nInput, nAfterBad, s.afterBadTelemetry); addLink(nAfterBad, nRejSpec, s.rejectedBySpec); addLink(nAfterBad, nAfterSpec, s.afterBySpec); addLink(nAfterSpec, nRejFp, s.rejectedByFingerprinting); addLink(nAfterSpec, nAfterFp, s.afterByFingerprinting); addLink(nAfterFp, nOutcomeZero, s.outcomeZeroCandidates); addLink(nAfterFp, nOutcomeOne, s.outcomeOneCandidate); addLink(nAfterFp, nOutcomeMulti, s.outcomeMultiCandidate); } this.chart.setOption({ title: { text: "Filter Pipeline", left: "left", }, tooltip: { trigger: "item", triggerOn: "mousemove", }, series: [ { type: "sankey", // Use automatic Sankey layout so each phase is positioned by graph depth. layoutIterations: 64, nodeAlign: "justify", nodeGap: 16, emphasis: { // Custom hover behavior below handles upstream-only highlighting. focus: "none", }, data: Array.from(nodeMap.values()), links: links, lineStyle: { color: "source", curveness: 0.5, }, }, ], animation: false, }, true); this.#InstallUpstreamHover(nodeMap, links); } ScheduleRenderSankey(slotStatsList) { let token = ++this.renderSankeyToken; let run = async () => { if (token != this.renderSankeyToken) { return; } await this.RenderSankey(slotStatsList); }; if (window.requestIdleCallback) { window.requestIdleCallback(() => { window.requestAnimationFrame(() => { run(); }); }, { timeout: 250 }); } else { window.setTimeout(() => { window.requestAnimationFrame(() => { run(); }); }, 0); } } #InstallUpstreamHover(nodeMap, links) { if (!this.chart) { return; } let nodeNameList = Array.from(nodeMap.keys()); let nodeNameSet = new Set(nodeNameList); let nodeIdxByName = new Map(); nodeNameList.forEach((name, idx) => nodeIdxByName.set(name, idx)); let incomingEdgeIdxByTarget = new Map(); for (let i = 0; i < links.length; ++i) { let l = links[i]; if (!incomingEdgeIdxByTarget.has(l.target)) { incomingEdgeIdxByTarget.set(l.target, []); } incomingEdgeIdxByTarget.get(l.target).push(i); } // Track highlighted items so we can downplay cleanly. this.sankeyHoverState = { nodeIdxSet: new Set(), edgeIdxSet: new Set(), }; let clearHighlight = () => { if (!this.sankeyHoverState) { return; } for (const idx of this.sankeyHoverState.nodeIdxSet) { this.chart.dispatchAction({ type: "downplay", seriesIndex: 0, dataType: "node", dataIndex: idx, }); } for (const idx of this.sankeyHoverState.edgeIdxSet) { this.chart.dispatchAction({ type: "downplay", seriesIndex: 0, dataType: "edge", dataIndex: idx, }); } this.sankeyHoverState.nodeIdxSet.clear(); this.sankeyHoverState.edgeIdxSet.clear(); }; let applyUpstreamHighlight = (seedNameList) => { clearHighlight(); let seenNameSet = new Set(); let stack = [...seedNameList]; while (stack.length) { let cur = stack.pop(); if (!cur || seenNameSet.has(cur)) { continue; } seenNameSet.add(cur); let nodeIdx = nodeIdxByName.get(cur); if (nodeIdx != undefined) { this.sankeyHoverState.nodeIdxSet.add(nodeIdx); } let edgeIdxList = incomingEdgeIdxByTarget.get(cur) || []; for (const edgeIdx of edgeIdxList) { this.sankeyHoverState.edgeIdxSet.add(edgeIdx); let src = links[edgeIdx].source; if (src && !seenNameSet.has(src)) { stack.push(src); } } } for (const idx of this.sankeyHoverState.nodeIdxSet) { this.chart.dispatchAction({ type: "highlight", seriesIndex: 0, dataType: "node", dataIndex: idx, }); } for (const idx of this.sankeyHoverState.edgeIdxSet) { this.chart.dispatchAction({ type: "highlight", seriesIndex: 0, dataType: "edge", dataIndex: idx, }); } }; this.chart.off("mouseover"); this.chart.off("globalout"); this.chart.on("mouseover", (params) => { if (!params || params.seriesType != "sankey") { return; } if (params.dataType == "node") { let name = params?.data?.name; if (nodeNameSet.has(name)) { applyUpstreamHighlight([name]); } } else if (params.dataType == "edge") { // Upstream of an edge means upstream of its target. let target = params?.data?.target; if (nodeNameSet.has(target)) { applyUpstreamHighlight([target]); } } }); this.chart.on("globalout", () => { clearHighlight(); }); } async EnsureChartReady() { if (this.chartReady) { return; } if (!this.chartLoadPromise) { this.chartLoadPromise = AsyncResourceLoader.AsyncLoadScript(WsprSearchUiStatsFilterController.urlEchartsScript); } try { await this.chartLoadPromise; if (!this.chart) { this.chart = echarts.init(this.chartDiv); } this.chartReady = true; } catch (e) { this.Err(`WsprSearchUiStatsFilterController`, `Could not init chart: ${e}`); } } MakeUI() { let ui = document.createElement('div'); this.ta = document.createElement('textarea'); this.ta.spellcheck = "false"; this.ta.readOnly = true; this.ta.disabled = true; this.ta.style.width = "600px"; this.ta.style.height = "260px"; this.chartDiv = document.createElement('div'); this.chartDiv.style.boxSizing = "border-box"; this.chartDiv.style.border = "1px solid black"; this.chartDiv.style.width = "1210px"; this.chartDiv.style.height = "800px"; this.chartDiv.style.marginTop = "8px"; ui.appendChild(this.ta); ui.appendChild(this.chartDiv); return ui; } }