594 lines
18 KiB
JavaScript
594 lines
18 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 { 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;
|
|
}
|
|
}
|