1887 lines
52 KiB
JavaScript
1887 lines
52 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 * as utl from '/js/Utl.js';
|
|
|
|
import { AsyncResourceLoader } from './AsyncResourceLoader.js';
|
|
import { Base } from './Base.js';
|
|
|
|
|
|
///////////////////////////////////////////////////////////////////////////////
|
|
// Chart Base
|
|
///////////////////////////////////////////////////////////////////////////////
|
|
|
|
class ChartBase
|
|
extends Base
|
|
{
|
|
static urlEchartsScript = `https://fastly.jsdelivr.net/npm/echarts@5.4.2/dist/echarts.min.js`;
|
|
|
|
static urlDatGuiScript = `https://cdn.jsdelivr.net/npm/dat.gui@0.7.9/build/dat.gui.min.js`;
|
|
static urlDatGuiCss = `https://cdn.jsdelivr.net/npm/dat.gui@0.7.9/build/dat.gui.min.css`;
|
|
|
|
constructor()
|
|
{
|
|
super();
|
|
|
|
this.ui = this.MakeUI();
|
|
this.data = null;
|
|
|
|
this.chart = echarts.init(this.ui);
|
|
|
|
this.HandleResizing();
|
|
|
|
this.resourcesOutstanding = 0;
|
|
this.LoadResources();
|
|
}
|
|
|
|
Ok()
|
|
{
|
|
return this.resourcesOutstanding == 0;
|
|
}
|
|
|
|
HandleResizing()
|
|
{
|
|
// This is very smooth, except when the resizing causes other page
|
|
// elements to move (especially big ones).
|
|
let isResizing = false;
|
|
const resizeObserver = new ResizeObserver(() => {
|
|
if (!isResizing)
|
|
{
|
|
isResizing = true;
|
|
|
|
window.requestAnimationFrame(() => {
|
|
this.chart.resize();
|
|
isResizing = false;
|
|
});
|
|
}
|
|
});
|
|
resizeObserver.observe(this.ui);
|
|
}
|
|
|
|
static GetExternalScriptResourceUrlList()
|
|
{
|
|
return [
|
|
ChartBase.urlEchartsScript,
|
|
ChartBase.urlDatGuiScript,
|
|
];
|
|
}
|
|
|
|
static GetExternalStylesheetResourceUrlList()
|
|
{
|
|
return [
|
|
ChartBase.urlDatGuiCss,
|
|
];
|
|
}
|
|
|
|
// This loads external resources on page load instead of when the chart is activated.
|
|
// ECharts took 150ms+ to load.
|
|
static PreLoadExternalResources()
|
|
{
|
|
let urlScriptList = [];
|
|
urlScriptList.push(... ChartBase.GetExternalScriptResourceUrlList());
|
|
for (const url of urlScriptList)
|
|
{
|
|
AsyncResourceLoader.AsyncLoadScript(url);
|
|
}
|
|
|
|
let urlStylesheetList = [];
|
|
urlStylesheetList.push(... ChartBase.GetExternalStylesheetResourceUrlList());
|
|
for (const url of urlStylesheetList)
|
|
{
|
|
AsyncResourceLoader.AsyncLoadStylesheet(url);
|
|
}
|
|
}
|
|
|
|
GetUI()
|
|
{
|
|
return this.ui;
|
|
}
|
|
|
|
PlotData(data)
|
|
{
|
|
// This can happen before, during, or after all external resources are loaded.
|
|
// In the event not all resources loaded, cache the data.
|
|
this.data = data;
|
|
|
|
if (this.resourcesOutstanding == 0)
|
|
{
|
|
this.PlotDataNow(this.data);
|
|
}
|
|
}
|
|
|
|
|
|
// private
|
|
|
|
MakeUI()
|
|
{
|
|
this.ui = document.createElement('div');
|
|
|
|
this.ui.innerHTML = "Chart"
|
|
this.ui.style.boxSizing = "border-box";
|
|
this.ui.style.border = "1px solid black";
|
|
this.ui.style.height = "300px";
|
|
// this.ui.style.height = "30vh";
|
|
this.ui.style.minHeight = "250px";
|
|
|
|
this.ui.style.resize = "both";
|
|
this.ui.style.overflow = "hidden"; // tooltips do this
|
|
|
|
return this.ui;
|
|
}
|
|
|
|
PlotDataNow(data)
|
|
{
|
|
// placeholder for inheriting classes to implement
|
|
}
|
|
|
|
LoadResources()
|
|
{
|
|
// script is critical, must wait for it to load
|
|
for (const url of ChartBase.GetExternalScriptResourceUrlList())
|
|
{
|
|
this.AsyncLoadScriptAndPlotIfAllComplete(url);
|
|
}
|
|
|
|
// css is not critical, load (or not), but we continue
|
|
for (const url of ChartBase.GetExternalStylesheetResourceUrlList())
|
|
{
|
|
AsyncResourceLoader.AsyncLoadStylesheet(url);
|
|
}
|
|
}
|
|
|
|
async AsyncLoadScriptAndPlotIfAllComplete(url)
|
|
{
|
|
try
|
|
{
|
|
++this.resourcesOutstanding;
|
|
|
|
await AsyncResourceLoader.AsyncLoadScript(url);
|
|
|
|
--this.resourcesOutstanding;
|
|
}
|
|
catch (e)
|
|
{
|
|
this.Err(`Chart`, `Could not load ${url} - ${e}.`)
|
|
}
|
|
|
|
// check if cached data to plot
|
|
if (this.data && this.resourcesOutstanding == 0)
|
|
{
|
|
this.PlotDataNow(this.data);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function PreLoadChartExternalResources()
|
|
{
|
|
ChartBase.PreLoadExternalResources();
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
///////////////////////////////////////////////////////////////////////////////
|
|
// ECharts Utils - just factoring out some common functionality
|
|
///////////////////////////////////////////////////////////////////////////////
|
|
|
|
class EChartsUtils
|
|
{
|
|
static GetUseSymbolForCurrentZoom(chart)
|
|
{
|
|
const axisInfo = chart.getModel().getComponent('xAxis').axis;
|
|
const [startValue, endValue] = axisInfo.scale.getExtent();
|
|
|
|
let MS_IN_24_HOURS = 24 * 60 * 60 * 1000;
|
|
let MS_IN_3_DAYS = MS_IN_24_HOURS * 3;
|
|
|
|
return ((endValue - startValue) <= MS_IN_3_DAYS);
|
|
}
|
|
|
|
static XAxisFormatter(params)
|
|
{
|
|
// convert the ms time value into human-readable
|
|
let ts = utl.MakeDateTimeFromMs(params.value);
|
|
|
|
// last char is could be an odd minute, let's eliminate that
|
|
let lastChar = ts.charAt(ts.length - 1);
|
|
if ("02468".indexOf(lastChar) == -1)
|
|
{
|
|
let lastCharNew = String.fromCharCode(lastChar.charCodeAt(0) - 1);
|
|
|
|
ts = ts.substring(0, ts.length - 1) + lastCharNew;
|
|
}
|
|
|
|
return ts;
|
|
};
|
|
|
|
static Pointer(params)
|
|
{
|
|
return EChartsUtils.RoundCommas(params.value);
|
|
};
|
|
|
|
static RoundCommas(val)
|
|
{
|
|
return utl.Commas(Math.round(val));
|
|
}
|
|
|
|
static OnZoomPan(chart)
|
|
{
|
|
let useSymbol = this.GetUseSymbolForCurrentZoom(chart);
|
|
|
|
let seriesCfgList = [];
|
|
for (let series in chart.getOption().series)
|
|
{
|
|
seriesCfgList.push({
|
|
symbol: useSymbol ? "circle" : "none",
|
|
symbolSize: 4,
|
|
});
|
|
}
|
|
// apply updated value
|
|
chart.setOption({
|
|
series: seriesCfgList,
|
|
});
|
|
};
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
///////////////////////////////////////////////////////////////////////////////
|
|
// ChartTimeSeriesBase
|
|
//
|
|
// https://echarts.apache.org/en/option.html
|
|
///////////////////////////////////////////////////////////////////////////////
|
|
|
|
export class ChartTimeSeriesBase
|
|
extends ChartBase
|
|
{
|
|
constructor()
|
|
{
|
|
super();
|
|
|
|
this.idRxGetZoom = null;
|
|
this.idRxSetZoom = null;
|
|
this.isApplyingSyncedZoom = false;
|
|
this.isDraggingZoom = false;
|
|
this.useSymbolForCurrentZoom = null;
|
|
this.lastZoomEmitMs = 0;
|
|
this.zoomSyncThrottleMs = 24;
|
|
this.zoomEmitTimeoutId = null;
|
|
this.wheelZoomDebounceMs = 40;
|
|
}
|
|
|
|
OnEvent(evt)
|
|
{
|
|
if (this.Ok())
|
|
{
|
|
switch (evt.type) {
|
|
case "TIME_SERIES_SET_ZOOM": this.OnSetZoom(evt); break;
|
|
}
|
|
}
|
|
}
|
|
|
|
OnSetZoom(evt)
|
|
{
|
|
if (evt.origin != this)
|
|
{
|
|
// make all the charts zoom asynchronously, which reduces jank a lot
|
|
|
|
// cache the latest data for the next time your callback fires.
|
|
// has the effect of doing update accumulation.
|
|
this.evtSetZoom = evt;
|
|
|
|
if (this.idRxSetZoom == null)
|
|
{
|
|
this.idRxSetZoom = window.requestAnimationFrame(() => {
|
|
this.isApplyingSyncedZoom = true;
|
|
this.chart.dispatchAction({
|
|
type: "dataZoom",
|
|
startValue: this.evtSetZoom.startValue,
|
|
endValue: this.evtSetZoom.endValue,
|
|
});
|
|
this.#UpdateSymbolVisibilityForZoom();
|
|
|
|
this.isApplyingSyncedZoom = false;
|
|
this.idRxSetZoom = null;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Plot any number of series in a single chart.
|
|
//
|
|
// Expects data to have the format:
|
|
// {
|
|
// td: TabularData,
|
|
//
|
|
// xAxisDetail: {
|
|
// column: "DateTimeLocal",
|
|
// },
|
|
//
|
|
// // put all series on two axes, or one
|
|
// yAxisMode: "two",
|
|
//
|
|
// yAxisDetailList: [
|
|
// {
|
|
// column: "Voltage",
|
|
// min : 3
|
|
// max : 4.95
|
|
// },
|
|
// ...
|
|
// ],
|
|
// }
|
|
PlotDataNow(data)
|
|
{
|
|
let td = data.td;
|
|
|
|
// cache
|
|
let timeCol = data.xAxisDetail.column;
|
|
|
|
// get series data
|
|
let seriesDataList = this.GetSeriesDataList(data);
|
|
|
|
// create chart options
|
|
let option = {};
|
|
|
|
// x-axis options
|
|
option.xAxis = this.GetOptionXAxis();
|
|
|
|
// zoom options
|
|
option.dataZoom = this.GetOptionDataZoom();
|
|
|
|
// y-axis options
|
|
option.yAxis = this.GetOptionYAxis(data);
|
|
|
|
// series options
|
|
option.series = this.GetOptionSeries(data, seriesDataList);
|
|
|
|
// tooltip options
|
|
option.tooltip = this.GetOptionTooltip(data, seriesDataList);
|
|
|
|
// animation options
|
|
option.animation = this.GetOptionAnimation();
|
|
|
|
// grid options
|
|
option.grid = this.GetOptionGrid();
|
|
|
|
// legend options
|
|
option.legend = this.GetOptionLegend();
|
|
|
|
this.OnPrePlot(option);
|
|
|
|
// plot
|
|
this.chart.setOption(option, true);
|
|
|
|
// apply initial zoom/pan-based logic
|
|
this.#UpdateSymbolVisibilityForZoom(true);
|
|
|
|
// handle zoom/pan, and let others join in on the zoom fun
|
|
this.chart.on('dataZoom', () => {
|
|
if (this.isApplyingSyncedZoom)
|
|
{
|
|
return;
|
|
}
|
|
|
|
const axisInfo = this.chart.getModel().getComponent('xAxis').axis;
|
|
const [startValue, endValue] = axisInfo.scale.getExtent();
|
|
|
|
// cache the latest data for the next time your callback fires.
|
|
// has the effect of doing update accumulation.
|
|
this.evtGetZoom = {
|
|
type: "TIME_SERIES_SET_ZOOM",
|
|
origin: this,
|
|
startValue,
|
|
endValue,
|
|
}
|
|
|
|
if (this.idRxGetZoom == null)
|
|
{
|
|
this.idRxGetZoom = window.requestAnimationFrame(() => {
|
|
if (!this.isDraggingZoom)
|
|
{
|
|
this.#UpdateSymbolVisibilityForZoom();
|
|
}
|
|
|
|
if (!this.isDraggingZoom)
|
|
{
|
|
this.#ScheduleTrailingZoomSync(this.wheelZoomDebounceMs);
|
|
}
|
|
else
|
|
{
|
|
let now = performance.now();
|
|
let msSinceLastEmit = now - this.lastZoomEmitMs;
|
|
let shouldEmit = msSinceLastEmit >= this.zoomSyncThrottleMs;
|
|
|
|
if (shouldEmit)
|
|
{
|
|
this.#EmitZoomSyncNow();
|
|
}
|
|
else if (this.zoomEmitTimeoutId == null)
|
|
{
|
|
let msDelay = Math.max(0, this.zoomSyncThrottleMs - msSinceLastEmit);
|
|
this.#ScheduleTrailingZoomSync(msDelay);
|
|
}
|
|
}
|
|
|
|
this.idRxGetZoom = null;
|
|
});
|
|
}
|
|
});
|
|
|
|
// reduce jank when dragging the chart
|
|
this.chart.getZr().on('mousedown', () => {
|
|
this.hideTooltip = true;
|
|
this.isDraggingZoom = true;
|
|
});
|
|
this.chart.getZr().on('mouseup', () => {
|
|
this.hideTooltip = false;
|
|
this.isDraggingZoom = false;
|
|
window.requestAnimationFrame(() => {
|
|
this.#UpdateSymbolVisibilityForZoom();
|
|
this.#EmitZoomSyncNow();
|
|
});
|
|
});
|
|
this.chart.getZr().on('globalout', () => {
|
|
this.hideTooltip = false;
|
|
this.isDraggingZoom = false;
|
|
});
|
|
}
|
|
|
|
#EmitZoomSyncNow()
|
|
{
|
|
if (!this.evtGetZoom)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (this.zoomEmitTimeoutId != null)
|
|
{
|
|
window.clearTimeout(this.zoomEmitTimeoutId);
|
|
this.zoomEmitTimeoutId = null;
|
|
}
|
|
|
|
this.lastZoomEmitMs = performance.now();
|
|
this.Emit(this.evtGetZoom);
|
|
}
|
|
|
|
#ScheduleTrailingZoomSync(msDelay)
|
|
{
|
|
if (this.zoomEmitTimeoutId != null)
|
|
{
|
|
window.clearTimeout(this.zoomEmitTimeoutId);
|
|
}
|
|
|
|
this.zoomEmitTimeoutId = window.setTimeout(() => {
|
|
this.zoomEmitTimeoutId = null;
|
|
if (this.evtGetZoom)
|
|
{
|
|
this.#EmitZoomSyncNow();
|
|
}
|
|
}, msDelay);
|
|
}
|
|
|
|
#UpdateSymbolVisibilityForZoom(force = false)
|
|
{
|
|
let useSymbol = EChartsUtils.GetUseSymbolForCurrentZoom(this.chart);
|
|
if (!force && this.useSymbolForCurrentZoom === useSymbol)
|
|
{
|
|
return;
|
|
}
|
|
|
|
this.useSymbolForCurrentZoom = useSymbol;
|
|
EChartsUtils.OnZoomPan(this.chart);
|
|
}
|
|
|
|
OnPrePlot(option)
|
|
{
|
|
// do nothing, this is for inheriting classes
|
|
}
|
|
|
|
GetSeriesDataList(data)
|
|
{
|
|
let td = data.td;
|
|
let timeCol = data.xAxisDetail.column;
|
|
|
|
// get series data
|
|
let seriesDataList = [];
|
|
for (const yAxisDetail of data.yAxisDetailList)
|
|
{
|
|
let seriesData = td.ExtractDataOnly([timeCol, yAxisDetail.column]);
|
|
|
|
seriesDataList.push(seriesData);
|
|
}
|
|
|
|
return seriesDataList;
|
|
}
|
|
|
|
GetOptionXAxis()
|
|
{
|
|
return {
|
|
type: "time",
|
|
axisPointer: {
|
|
show: true,
|
|
label: {
|
|
formatter: EChartsUtils.XAxisFormatter,
|
|
},
|
|
},
|
|
axisLabel: {
|
|
formatter: {
|
|
day: "{MMM} {d}",
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
GetOptionDataZoom()
|
|
{
|
|
return [
|
|
{
|
|
type: 'inside',
|
|
filterMode: "none",
|
|
throttle: 16,
|
|
},
|
|
];
|
|
}
|
|
|
|
GetOptionYAxis(data)
|
|
{
|
|
let yAxisObjList = [];
|
|
for (let i = 0; i < data.yAxisDetailList.length; ++i)
|
|
{
|
|
let obj = {
|
|
type: "value",
|
|
name: data.yAxisDetailList[i].column,
|
|
|
|
// only show y-axis split from first y-axis
|
|
splitLine: {
|
|
show: i ? false : true,
|
|
},
|
|
|
|
axisPointer: {
|
|
show: true,
|
|
label: {
|
|
formatter: EChartsUtils.Pointer,
|
|
},
|
|
},
|
|
axisLabel: {
|
|
// formatter: EChartsUtils.RoundCommas,
|
|
},
|
|
};
|
|
|
|
let min = data.yAxisDetailList[i].min;
|
|
let max = data.yAxisDetailList[i].max;
|
|
if (i == 0)
|
|
{
|
|
// first series always on the left-axis
|
|
|
|
if (min != undefined) { obj.min = min; }
|
|
if (max != undefined) { obj.max = max; }
|
|
}
|
|
else
|
|
{
|
|
if (data.yAxisMode == "one")
|
|
{
|
|
// can also assign the right-side y-axis to be the same values as left
|
|
// if that looks nicer
|
|
// obj.min = data.yAxisDetailList[0].min;
|
|
// obj.max = data.yAxisDetailList[0].max;
|
|
}
|
|
else
|
|
{
|
|
// use the specified min/max for this series
|
|
if (min != undefined) { obj.min = min; }
|
|
if (max != undefined) { obj.max = max; }
|
|
}
|
|
}
|
|
|
|
yAxisObjList.push(obj);
|
|
}
|
|
|
|
return yAxisObjList;
|
|
}
|
|
|
|
GetOptionSeries(data, seriesDataList)
|
|
{
|
|
let seriesObjList = [];
|
|
for (let i = 0; i < data.yAxisDetailList.length; ++i)
|
|
{
|
|
let obj = {
|
|
name: data.yAxisDetailList[i].column,
|
|
type: "line",
|
|
|
|
yAxisIndex: data.yAxisMode == "one" ? 0 : i,
|
|
|
|
data: seriesDataList[i],
|
|
connectNulls: true,
|
|
};
|
|
|
|
if (seriesDataList[i].length >= 1)
|
|
{
|
|
obj.symbol = "none";
|
|
}
|
|
|
|
seriesObjList.push(obj);
|
|
}
|
|
|
|
return seriesObjList;
|
|
}
|
|
|
|
GetOptionTooltip(data, seriesDataList)
|
|
{
|
|
return {
|
|
show: true,
|
|
trigger: "axis",
|
|
confine: true,
|
|
formatter: params => {
|
|
let retVal = undefined;
|
|
|
|
// reduces jank when dragging the chart
|
|
if (this.hideTooltip) { return retVal; }
|
|
|
|
let idx = params[0].dataIndex;
|
|
|
|
let msg = ``;
|
|
|
|
let sep = ``;
|
|
let countWithVal = 0;
|
|
for (let i = 0; i < data.yAxisDetailList.length; ++i)
|
|
{
|
|
let col = data.yAxisDetailList[i].column;
|
|
let val = seriesDataList[i][idx][1];
|
|
|
|
if (val == undefined)
|
|
{
|
|
val = "";
|
|
}
|
|
else
|
|
{
|
|
++countWithVal;
|
|
|
|
val = utl.Commas(val);
|
|
}
|
|
|
|
msg += sep;
|
|
msg += `${col}: ${val}`;
|
|
|
|
sep = `<br/>`;
|
|
}
|
|
|
|
msg += `<br/>`;
|
|
msg += `<br/>`;
|
|
msg += params[0].data[0]; // timestamp
|
|
|
|
if (countWithVal)
|
|
{
|
|
retVal = msg;
|
|
}
|
|
|
|
return retVal;
|
|
},
|
|
};
|
|
}
|
|
|
|
GetOptionAnimation()
|
|
{
|
|
return false;
|
|
}
|
|
|
|
GetOptionGrid()
|
|
{
|
|
return {
|
|
top: "40px",
|
|
left: "50px",
|
|
bottom: "30px",
|
|
};
|
|
}
|
|
|
|
GetOptionLegend()
|
|
{
|
|
return {
|
|
show: true,
|
|
};
|
|
}
|
|
}
|
|
|
|
|
|
///////////////////////////////////////////////////////////////////////////////
|
|
// ChartTimeSeries
|
|
///////////////////////////////////////////////////////////////////////////////
|
|
|
|
export class ChartTimeSeries
|
|
extends ChartTimeSeriesBase
|
|
{
|
|
constructor()
|
|
{
|
|
super();
|
|
}
|
|
|
|
OnPrePlot(option)
|
|
{
|
|
// virtual
|
|
}
|
|
}
|
|
|
|
///////////////////////////////////////////////////////////////////////////////
|
|
// ChartTimeSeriesBar
|
|
///////////////////////////////////////////////////////////////////////////////
|
|
|
|
export class ChartTimeSeriesBar
|
|
extends ChartTimeSeriesBase
|
|
{
|
|
constructor()
|
|
{
|
|
super();
|
|
}
|
|
|
|
GetOptionSeries(data, seriesDataList)
|
|
{
|
|
let seriesObjList = [];
|
|
for (let i = 0; i < data.yAxisDetailList.length; ++i)
|
|
{
|
|
seriesObjList.push({
|
|
name: data.yAxisDetailList[i].column,
|
|
type: "bar",
|
|
yAxisIndex: data.yAxisMode == "one" ? 0 : i,
|
|
data: seriesDataList[i],
|
|
barMaxWidth: 24,
|
|
});
|
|
}
|
|
|
|
return seriesObjList;
|
|
}
|
|
}
|
|
|
|
///////////////////////////////////////////////////////////////////////////////
|
|
// ChartHistogramBar
|
|
///////////////////////////////////////////////////////////////////////////////
|
|
|
|
export class ChartHistogramBar
|
|
extends ChartBase
|
|
{
|
|
constructor()
|
|
{
|
|
super();
|
|
}
|
|
|
|
PlotDataNow(data)
|
|
{
|
|
let bucketLabelList = data.bucketLabelList || [];
|
|
let bucketCountList = data.bucketCountList || [];
|
|
let xAxisName = data.xAxisName || "";
|
|
let yAxisName = data.yAxisName || "";
|
|
let grid = data.grid || {
|
|
top: 30,
|
|
left: 34,
|
|
right: 14,
|
|
bottom: 56,
|
|
};
|
|
let xAxisNameGap = data.xAxisNameGap ?? 42;
|
|
let xAxisLabelRotate = data.xAxisLabelRotate ?? 45;
|
|
let xAxisLabelMargin = data.xAxisLabelMargin ?? 6;
|
|
let yAxisNameGap = data.yAxisNameGap ?? 10;
|
|
|
|
this.chart.setOption({
|
|
grid,
|
|
xAxis: {
|
|
type: "category",
|
|
data: bucketLabelList,
|
|
name: xAxisName,
|
|
nameLocation: "middle",
|
|
nameGap: xAxisNameGap,
|
|
axisLabel: {
|
|
interval: 1,
|
|
rotate: xAxisLabelRotate,
|
|
fontSize: 10,
|
|
margin: xAxisLabelMargin,
|
|
showMaxLabel: true,
|
|
hideOverlap: false,
|
|
},
|
|
},
|
|
yAxis: {
|
|
type: "value",
|
|
name: yAxisName,
|
|
nameGap: yAxisNameGap,
|
|
min: 0,
|
|
minInterval: 1,
|
|
},
|
|
tooltip: {
|
|
trigger: "axis",
|
|
},
|
|
series: [
|
|
{
|
|
type: "bar",
|
|
data: bucketCountList,
|
|
barMaxWidth: 20,
|
|
},
|
|
],
|
|
animation: false,
|
|
}, true);
|
|
}
|
|
}
|
|
|
|
|
|
///////////////////////////////////////////////////////////////////////////////
|
|
// ChartTimeSeriesTwoSeriesOneLine
|
|
//
|
|
// Specialty class for plotting (say) the same value in both
|
|
// Metric and Imperial.
|
|
//
|
|
// Overcomes the problem that plotting the same (but converted units) series
|
|
// on the same plot _almost_ works, but has tiny imperfections where the lines
|
|
// don't perfectly overlap.
|
|
///////////////////////////////////////////////////////////////////////////////
|
|
|
|
export class ChartTimeSeriesTwoEqualSeriesOneLine
|
|
extends ChartTimeSeriesBase
|
|
{
|
|
constructor()
|
|
{
|
|
super();
|
|
}
|
|
|
|
OnPrePlot(option)
|
|
{
|
|
if (option.series.length >= 1)
|
|
{
|
|
delete option.series[1].data;
|
|
}
|
|
|
|
option.legend = false;
|
|
}
|
|
}
|
|
|
|
///////////////////////////////////////////////////////////////////////////////
|
|
// ChartTimeSeriesTwoEqualSeriesOneLinePlus
|
|
//
|
|
// Specialty class for plotting:
|
|
// - the same value in both (say) Metric and Imperial
|
|
// - then two extra series, which gets no credit on the y-axis
|
|
//
|
|
// The chart axes are:
|
|
// - left y-axis values : series 0
|
|
// - left y-axis min/max: series 2
|
|
// - right y-axis values : series 1
|
|
// - right y-axis min/max: series 3
|
|
///////////////////////////////////////////////////////////////////////////////
|
|
|
|
export class ChartTimeSeriesTwoEqualSeriesOneLinePlus
|
|
extends ChartTimeSeriesTwoEqualSeriesOneLine
|
|
{
|
|
constructor()
|
|
{
|
|
super();
|
|
}
|
|
|
|
OnPrePlot(option)
|
|
{
|
|
super.OnPrePlot(option);
|
|
|
|
if (option.series.length >= 4)
|
|
{
|
|
// we overwrite the 2nd series configuration (which we don't want to plot anyway)
|
|
// and move it to the first y-axis
|
|
option.series[1].yAxisIndex = 0;
|
|
option.series[1].data = option.series[2].data;
|
|
|
|
// update axes
|
|
option.yAxis[0].min = option.yAxis[2].min;
|
|
option.yAxis[0].max = option.yAxis[2].max;
|
|
|
|
option.yAxis[1].min = option.yAxis[3].min;
|
|
option.yAxis[1].max = option.yAxis[3].max;
|
|
|
|
// we destroy the 3rd+ series data so the chart ignores it
|
|
option.series.length = 2;
|
|
option.yAxis.length = 2;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
///////////////////////////////////////////////////////////////////////////////
|
|
// ChartScatterSeriesPicker
|
|
//
|
|
// Scatter plot any two selected numeric series against each other.
|
|
///////////////////////////////////////////////////////////////////////////////
|
|
|
|
export class ChartScatterSeriesPicker
|
|
extends ChartBase
|
|
{
|
|
static COOKIE_MODE = "wspr_scatter_mode";
|
|
static COOKIE_X = "wspr_scatter_x_series";
|
|
static COOKIE_Y = "wspr_scatter_y_series";
|
|
static COOKIE_HISTOGRAM_SERIES_SETTINGS = "wspr_histogram_series_settings";
|
|
static COOKIE_GUI_CLOSED = "wspr_scatter_gui_closed";
|
|
|
|
constructor()
|
|
{
|
|
super();
|
|
|
|
this.gui = null;
|
|
this.guiState = {
|
|
mode: "Scatter",
|
|
xSeries: "",
|
|
ySeries: "",
|
|
bucketSize: "",
|
|
minValue: "",
|
|
maxValue: "",
|
|
};
|
|
this.histogramSeriesSettingsByName = {};
|
|
|
|
this.ui.style.position = "relative";
|
|
}
|
|
|
|
PlotDataNow(data)
|
|
{
|
|
this.data = data;
|
|
|
|
let td = data.td;
|
|
let colNameList = (data.colNameList || []).filter(col => td.Idx(col) != undefined);
|
|
|
|
if (colNameList.length < 1)
|
|
{
|
|
this.chart.setOption({
|
|
title: { text: "Scatter / Histogram: need at least one series" },
|
|
}, true);
|
|
return;
|
|
}
|
|
|
|
let preferredXList = this.NormalizePreferredList(data.preferredXSeriesList ?? data.preferredXSeries);
|
|
let preferredYList = this.NormalizePreferredList(data.preferredYSeriesList ?? data.preferredYSeries);
|
|
let resolved = this.ResolveInitialSelection(colNameList, preferredXList, preferredYList);
|
|
this.#LoadHistogramSeriesSettingsFromCookie();
|
|
this.guiState.mode = this.ResolveInitialMode();
|
|
this.guiState.xSeries = resolved.xSeries;
|
|
this.guiState.ySeries = resolved.ySeries;
|
|
this.#ApplyHistogramSeriesSettings(this.guiState.xSeries);
|
|
|
|
this.SetupGui(colNameList);
|
|
this.RenderCurrentMode();
|
|
}
|
|
|
|
SetupGui(colNameList)
|
|
{
|
|
if (typeof dat == "undefined" || !dat.GUI)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (this.gui)
|
|
{
|
|
this.gui.destroy();
|
|
this.gui = null;
|
|
}
|
|
|
|
this.gui = new dat.GUI({ autoPlace: false, width: 220 });
|
|
this.gui.domElement.style.position = "absolute";
|
|
this.gui.domElement.style.top = "4px";
|
|
this.gui.domElement.style.right = "4px";
|
|
this.gui.domElement.style.left = "auto";
|
|
this.gui.domElement.style.zIndex = "2";
|
|
this.#UpdateGuiLayout(colNameList);
|
|
this.ui.appendChild(this.gui.domElement);
|
|
this.#InstallGuiOpenCloseButton();
|
|
|
|
let modeController = this.gui.add(this.guiState, "mode", ["Scatter", "Histogram"]).name("Mode").onChange(() => {
|
|
this.WriteCookie(ChartScatterSeriesPicker.COOKIE_MODE, this.guiState.mode);
|
|
this.#UpdateModeVisibility();
|
|
this.RenderCurrentMode();
|
|
});
|
|
|
|
let yController = this.gui.add(this.guiState, "ySeries", colNameList).name("Y Series").onChange(() => {
|
|
this.OnAxisSelectionChanged("y", colNameList);
|
|
});
|
|
|
|
let xController = this.gui.add(this.guiState, "xSeries", colNameList).name("X Series").onChange(() => {
|
|
this.OnAxisSelectionChanged("x", colNameList);
|
|
});
|
|
|
|
let bucketController = this.gui.add(this.guiState, "bucketSize").name("Bucket Size").onFinishChange(() => {
|
|
this.NormalizeBucketSize();
|
|
this.#PersistCurrentHistogramSeriesSettings();
|
|
this.bucketController?.updateDisplay();
|
|
this.RenderCurrentMode();
|
|
});
|
|
|
|
let minController = this.gui.add(this.guiState, "minValue").name("Min").onFinishChange(() => {
|
|
this.NormalizeRangeBounds();
|
|
this.#PersistCurrentHistogramSeriesSettings();
|
|
this.minController?.updateDisplay();
|
|
this.maxController?.updateDisplay();
|
|
this.RenderCurrentMode();
|
|
});
|
|
|
|
let maxController = this.gui.add(this.guiState, "maxValue").name("Max").onFinishChange(() => {
|
|
this.NormalizeRangeBounds();
|
|
this.#PersistCurrentHistogramSeriesSettings();
|
|
this.minController?.updateDisplay();
|
|
this.maxController?.updateDisplay();
|
|
this.RenderCurrentMode();
|
|
});
|
|
|
|
this.#ApplyDatGuiControllerLayout(modeController);
|
|
this.#ApplyDatGuiControllerLayout(xController);
|
|
this.#ApplyDatGuiControllerLayout(yController);
|
|
this.#ApplyDatGuiControllerLayout(bucketController);
|
|
this.#ApplyDatGuiControllerLayout(minController);
|
|
this.#ApplyDatGuiControllerLayout(maxController);
|
|
|
|
this.modeController = modeController;
|
|
this.xController = xController;
|
|
this.yController = yController;
|
|
this.bucketController = bucketController;
|
|
this.minController = minController;
|
|
this.maxController = maxController;
|
|
|
|
this.#HookGuiOpenClosePersistence();
|
|
this.#ApplyGuiOpenClosePreference();
|
|
this.#UpdateModeVisibility();
|
|
}
|
|
|
|
OnAxisSelectionChanged(axisChanged, colNameList)
|
|
{
|
|
// Keep both visible selectors synchronized when one changes the other.
|
|
this.xController?.updateDisplay();
|
|
this.yController?.updateDisplay();
|
|
|
|
this.WriteCookie(ChartScatterSeriesPicker.COOKIE_X, this.guiState.xSeries);
|
|
this.WriteCookie(ChartScatterSeriesPicker.COOKIE_Y, this.guiState.ySeries);
|
|
this.#ApplyHistogramSeriesSettings(this.guiState.xSeries);
|
|
this.bucketController?.updateDisplay();
|
|
this.minController?.updateDisplay();
|
|
this.maxController?.updateDisplay();
|
|
this.RenderCurrentMode();
|
|
}
|
|
|
|
#UpdateGuiLayout(colNameList)
|
|
{
|
|
// Estimate width from the longest selectable series name, then clamp
|
|
// so the GUI never renders outside the chart while staying right-aligned.
|
|
let longest = 0;
|
|
for (const colName of colNameList)
|
|
{
|
|
longest = Math.max(longest, String(colName).length);
|
|
}
|
|
|
|
let widthByText = 155 + (longest * 7);
|
|
let desiredWidth = Math.min(500, Math.max(240, widthByText));
|
|
|
|
let chartInnerWidth = Math.max(0, this.ui.clientWidth - 10);
|
|
let widthUse = Math.max(190, Math.min(desiredWidth, chartInnerWidth));
|
|
|
|
this.gui.width = widthUse;
|
|
this.gui.domElement.style.width = `${widthUse}px`;
|
|
this.gui.domElement.style.maxWidth = "calc(100% - 8px)";
|
|
}
|
|
|
|
#ApplyDatGuiControllerLayout(controller)
|
|
{
|
|
if (!controller || !controller.domElement)
|
|
{
|
|
return;
|
|
}
|
|
|
|
let row = controller.domElement;
|
|
let nameEl = row.querySelector(".property-name");
|
|
let controlEl = row.querySelector(".c");
|
|
let inputEl = row.querySelector("select, input");
|
|
|
|
if (nameEl)
|
|
{
|
|
nameEl.style.width = "92px";
|
|
}
|
|
if (controlEl)
|
|
{
|
|
controlEl.style.width = "calc(100% - 92px)";
|
|
controlEl.style.boxSizing = "border-box";
|
|
controlEl.style.paddingRight = "2px";
|
|
}
|
|
if (inputEl)
|
|
{
|
|
inputEl.style.width = "100%";
|
|
inputEl.style.boxSizing = "border-box";
|
|
inputEl.style.maxWidth = "100%";
|
|
}
|
|
}
|
|
|
|
#UpdateModeVisibility()
|
|
{
|
|
let scatterMode = this.guiState.mode == "Scatter";
|
|
let setDisplay = (controller, visible) => {
|
|
let row = controller?.domElement?.closest?.("li") || controller?.domElement;
|
|
if (!row)
|
|
{
|
|
return;
|
|
}
|
|
|
|
row.hidden = !visible;
|
|
row.style.display = visible ? "" : "none";
|
|
row.style.visibility = visible ? "" : "hidden";
|
|
row.style.height = visible ? "" : "0";
|
|
row.style.minHeight = visible ? "" : "0";
|
|
row.style.margin = visible ? "" : "0";
|
|
row.style.padding = visible ? "" : "0";
|
|
row.style.border = visible ? "" : "0";
|
|
row.style.overflow = visible ? "" : "hidden";
|
|
};
|
|
|
|
setDisplay(this.yController, scatterMode);
|
|
setDisplay(this.bucketController, !scatterMode);
|
|
setDisplay(this.minController, !scatterMode);
|
|
setDisplay(this.maxController, !scatterMode);
|
|
}
|
|
|
|
RenderCurrentMode()
|
|
{
|
|
if (this.guiState.mode == "Histogram")
|
|
{
|
|
this.RenderHistogram();
|
|
}
|
|
else
|
|
{
|
|
this.RenderScatter();
|
|
}
|
|
}
|
|
|
|
RenderScatter()
|
|
{
|
|
if (!this.data || !this.data.td)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (this.gui)
|
|
{
|
|
this.#UpdateGuiLayout(this.data.colNameList || []);
|
|
}
|
|
|
|
let td = this.data.td;
|
|
let xSeries = this.guiState.xSeries;
|
|
let ySeries = this.guiState.ySeries;
|
|
|
|
if (!xSeries || !ySeries)
|
|
{
|
|
this.chart.setOption({
|
|
title: { text: "Scatter: need at least two series" },
|
|
}, true);
|
|
return;
|
|
}
|
|
|
|
let pairListRaw = td.ExtractDataOnly([xSeries, ySeries]);
|
|
let pairList = [];
|
|
|
|
for (const pair of pairListRaw)
|
|
{
|
|
if (pair[0] == null || pair[0] === "" || pair[1] == null || pair[1] === "")
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let xVal = Number(pair[0]);
|
|
let yVal = Number(pair[1]);
|
|
|
|
if (Number.isFinite(xVal) && Number.isFinite(yVal))
|
|
{
|
|
pairList.push([xVal, yVal]);
|
|
}
|
|
}
|
|
|
|
this.chart.setOption({
|
|
grid: {
|
|
top: 30,
|
|
left: 60,
|
|
right: 30,
|
|
bottom: 40,
|
|
},
|
|
xAxis: {
|
|
type: "value",
|
|
name: xSeries,
|
|
nameLocation: "middle",
|
|
nameGap: 28,
|
|
axisLabel: {
|
|
formatter: EChartsUtils.RoundCommas,
|
|
},
|
|
},
|
|
yAxis: {
|
|
type: "value",
|
|
name: ySeries,
|
|
nameLocation: "middle",
|
|
nameGap: 45,
|
|
axisLabel: {
|
|
formatter: EChartsUtils.RoundCommas,
|
|
},
|
|
},
|
|
tooltip: {
|
|
trigger: "item",
|
|
formatter: params => `${xSeries}: ${utl.Commas(params.value[0])}<br/>${ySeries}: ${utl.Commas(params.value[1])}`,
|
|
},
|
|
series: [
|
|
{
|
|
type: "scatter",
|
|
symbolSize: 6,
|
|
data: pairList,
|
|
},
|
|
],
|
|
animation: false,
|
|
}, true);
|
|
}
|
|
|
|
ResolveInitialSelection(colNameList, preferredXList, preferredYList)
|
|
{
|
|
let cookieX = this.ReadCookie(ChartScatterSeriesPicker.COOKIE_X);
|
|
let cookieY = this.ReadCookie(ChartScatterSeriesPicker.COOKIE_Y);
|
|
|
|
let xSeries = this.FirstValidColumn(colNameList, [cookieX, ...preferredXList, ...colNameList]);
|
|
let ySeries = this.FirstValidColumn(colNameList, [cookieY, ...preferredYList, ...colNameList]);
|
|
|
|
// final fallback for degenerate datasets
|
|
if (xSeries == undefined) { xSeries = colNameList[0]; }
|
|
if (ySeries == undefined) { ySeries = colNameList[Math.min(1, colNameList.length - 1)]; }
|
|
|
|
return { xSeries, ySeries };
|
|
}
|
|
|
|
ResolveInitialMode()
|
|
{
|
|
let mode = this.ReadCookie(ChartScatterSeriesPicker.COOKIE_MODE);
|
|
return mode == "Histogram" ? "Histogram" : "Scatter";
|
|
}
|
|
|
|
NormalizePreferredList(preferred)
|
|
{
|
|
if (Array.isArray(preferred))
|
|
{
|
|
return preferred.filter(v => typeof v == "string" && v.trim() != "");
|
|
}
|
|
|
|
if (typeof preferred == "string" && preferred.trim() != "")
|
|
{
|
|
return [preferred];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
FirstValidColumn(colNameList, candidateList, disallow)
|
|
{
|
|
for (const candidate of candidateList)
|
|
{
|
|
if (candidate && colNameList.includes(candidate) && candidate != disallow)
|
|
{
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
ReadCookie(name)
|
|
{
|
|
let prefix = `${name}=`;
|
|
let partList = document.cookie.split(";");
|
|
|
|
for (let part of partList)
|
|
{
|
|
part = part.trim();
|
|
if (part.startsWith(prefix))
|
|
{
|
|
return decodeURIComponent(part.substring(prefix.length));
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
WriteCookie(name, val)
|
|
{
|
|
// Use a far-future expiry so preferences effectively do not expire.
|
|
let expires = "Fri, 31 Dec 9999 23:59:59 GMT";
|
|
document.cookie = `${name}=${encodeURIComponent(val)}; Expires=${expires}; Path=/; SameSite=Lax`;
|
|
}
|
|
|
|
WriteCookieDays(name, val, days)
|
|
{
|
|
let ms = days * 24 * 60 * 60 * 1000;
|
|
let expires = new Date(Date.now() + ms).toUTCString();
|
|
document.cookie = `${name}=${encodeURIComponent(val)}; Expires=${expires}; Path=/; SameSite=Lax`;
|
|
}
|
|
|
|
DeleteCookie(name)
|
|
{
|
|
document.cookie = `${name}=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; SameSite=Lax`;
|
|
}
|
|
|
|
#LoadHistogramSeriesSettingsFromCookie()
|
|
{
|
|
let json = this.ReadCookie(ChartScatterSeriesPicker.COOKIE_HISTOGRAM_SERIES_SETTINGS);
|
|
if (!json)
|
|
{
|
|
this.histogramSeriesSettingsByName = {};
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
let parsed = JSON.parse(json);
|
|
this.histogramSeriesSettingsByName = (parsed && typeof parsed == "object") ? parsed : {};
|
|
}
|
|
catch
|
|
{
|
|
this.histogramSeriesSettingsByName = {};
|
|
}
|
|
}
|
|
|
|
#SaveHistogramSeriesSettingsToCookie()
|
|
{
|
|
this.WriteCookie(ChartScatterSeriesPicker.COOKIE_HISTOGRAM_SERIES_SETTINGS, JSON.stringify(this.histogramSeriesSettingsByName));
|
|
}
|
|
|
|
#GetDefaultHistogramSeriesSettings(colName)
|
|
{
|
|
return {
|
|
bucketSize: this.GetInitialBucketSizeForSeries(this.data.td, colName),
|
|
minValue: "",
|
|
maxValue: "",
|
|
};
|
|
}
|
|
|
|
#ApplyHistogramSeriesSettings(colName)
|
|
{
|
|
let saved = this.histogramSeriesSettingsByName[colName];
|
|
let defaults = this.#GetDefaultHistogramSeriesSettings(colName);
|
|
|
|
this.guiState.bucketSize = saved?.bucketSize ?? defaults.bucketSize;
|
|
this.guiState.minValue = saved?.minValue ?? defaults.minValue;
|
|
this.guiState.maxValue = saved?.maxValue ?? defaults.maxValue;
|
|
this.NormalizeBucketSize();
|
|
this.NormalizeRangeBounds();
|
|
this.#PersistCurrentHistogramSeriesSettings();
|
|
}
|
|
|
|
#PersistCurrentHistogramSeriesSettings()
|
|
{
|
|
let colName = this.guiState.xSeries;
|
|
if (!colName)
|
|
{
|
|
return;
|
|
}
|
|
|
|
this.histogramSeriesSettingsByName[colName] = {
|
|
bucketSize: this.#FormatBucketSize(Number(this.guiState.bucketSize)),
|
|
minValue: this.guiState.minValue ?? "",
|
|
maxValue: this.guiState.maxValue ?? "",
|
|
};
|
|
this.#SaveHistogramSeriesSettingsToCookie();
|
|
}
|
|
|
|
#ApplyGuiOpenClosePreference()
|
|
{
|
|
if (!this.gui)
|
|
{
|
|
return;
|
|
}
|
|
|
|
let isClosed = this.ReadCookie(ChartScatterSeriesPicker.COOKIE_GUI_CLOSED) == "1";
|
|
if (isClosed)
|
|
{
|
|
this.gui.close();
|
|
}
|
|
else
|
|
{
|
|
// Default-open behavior when cookie is absent/expired.
|
|
this.gui.open();
|
|
}
|
|
}
|
|
|
|
#PersistGuiOpenClosePreference()
|
|
{
|
|
if (!this.gui)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (this.gui.closed)
|
|
{
|
|
// Remember closed state for 14 days, then forget back to default-open.
|
|
this.WriteCookieDays(ChartScatterSeriesPicker.COOKIE_GUI_CLOSED, "1", 14);
|
|
}
|
|
else
|
|
{
|
|
// Open is default behavior; no need to persist.
|
|
this.DeleteCookie(ChartScatterSeriesPicker.COOKIE_GUI_CLOSED);
|
|
}
|
|
}
|
|
|
|
#UpdateGuiOpenCloseLabel()
|
|
{
|
|
let button = this.guiOpenCloseButton;
|
|
if (!button)
|
|
{
|
|
return;
|
|
}
|
|
|
|
button.textContent = this.gui.closed ? "Open Configuration" : "Close Configuration";
|
|
}
|
|
|
|
#InstallGuiOpenCloseButton()
|
|
{
|
|
let guiEl = this.gui?.domElement;
|
|
let nativeCloseButton = guiEl?.querySelector(".close-button");
|
|
let listEl = guiEl?.querySelector("ul");
|
|
if (!guiEl || !nativeCloseButton || !listEl)
|
|
{
|
|
return;
|
|
}
|
|
|
|
nativeCloseButton.style.display = "none";
|
|
|
|
if (!this.guiOpenCloseButton)
|
|
{
|
|
let button = document.createElement("div");
|
|
button.className = "close-button";
|
|
button.style.position = "relative";
|
|
button.style.bottom = "auto";
|
|
button.style.margin = "0";
|
|
button.style.width = "100%";
|
|
button.style.boxSizing = "border-box";
|
|
button.style.cursor = "pointer";
|
|
button.addEventListener("click", () => {
|
|
if (this.gui.closed)
|
|
{
|
|
this.gui.open();
|
|
}
|
|
else
|
|
{
|
|
this.gui.close();
|
|
}
|
|
});
|
|
this.guiOpenCloseButton = button;
|
|
}
|
|
|
|
if (this.guiOpenCloseButton.parentElement !== guiEl)
|
|
{
|
|
guiEl.insertBefore(this.guiOpenCloseButton, listEl);
|
|
}
|
|
}
|
|
|
|
#HookGuiOpenClosePersistence()
|
|
{
|
|
if (!this.gui)
|
|
{
|
|
return;
|
|
}
|
|
|
|
let openOriginal = this.gui.open.bind(this.gui);
|
|
this.gui.open = () => {
|
|
openOriginal();
|
|
this.#PersistGuiOpenClosePreference();
|
|
this.#UpdateGuiOpenCloseLabel();
|
|
return this.gui;
|
|
};
|
|
|
|
let closeOriginal = this.gui.close.bind(this.gui);
|
|
this.gui.close = () => {
|
|
closeOriginal();
|
|
this.#PersistGuiOpenClosePreference();
|
|
this.#UpdateGuiOpenCloseLabel();
|
|
return this.gui;
|
|
};
|
|
|
|
this.#UpdateGuiOpenCloseLabel();
|
|
}
|
|
GetInitialBucketSizeForSeries(td, colName)
|
|
{
|
|
let valueListRaw = td.ExtractDataOnly([colName]);
|
|
let valueList = [];
|
|
let maxDecimals = 0;
|
|
|
|
for (const row of valueListRaw)
|
|
{
|
|
let value = row[0];
|
|
let num = Number(value);
|
|
if (!Number.isFinite(num))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
valueList.push(num);
|
|
|
|
let text = String(value).trim();
|
|
if (text == "")
|
|
{
|
|
text = String(num);
|
|
}
|
|
|
|
let decimals = 0;
|
|
if (/[eE]/.test(text))
|
|
{
|
|
let fixed = num.toFixed(12).replace(/0+$/, "").replace(/\.$/, "");
|
|
let dot = fixed.indexOf(".");
|
|
decimals = dot == -1 ? 0 : (fixed.length - dot - 1);
|
|
}
|
|
else
|
|
{
|
|
let dot = text.indexOf(".");
|
|
decimals = dot == -1 ? 0 : (text.length - dot - 1);
|
|
}
|
|
|
|
if (decimals > maxDecimals)
|
|
{
|
|
maxDecimals = decimals;
|
|
}
|
|
}
|
|
|
|
if (valueList.length == 0)
|
|
{
|
|
return "1";
|
|
}
|
|
|
|
valueList.sort((a, b) => a - b);
|
|
|
|
let minValue = valueList[0];
|
|
let maxValue = valueList[valueList.length - 1];
|
|
let range = maxValue - minValue;
|
|
let precisionStep = 10 ** (-maxDecimals);
|
|
if (!Number.isFinite(precisionStep) || precisionStep <= 0)
|
|
{
|
|
precisionStep = 1;
|
|
}
|
|
|
|
if (range <= 0)
|
|
{
|
|
return this.#FormatBucketSize(precisionStep);
|
|
}
|
|
|
|
let bucketWidth = this.#GetFreedmanDiaconisWidth(valueList);
|
|
if (!Number.isFinite(bucketWidth) || bucketWidth <= 0)
|
|
{
|
|
let sqrtBucketCount = Math.max(1, Math.ceil(Math.sqrt(valueList.length)));
|
|
bucketWidth = range / sqrtBucketCount;
|
|
}
|
|
|
|
let minBucketCount = 8;
|
|
let maxBucketCount = 24;
|
|
let minWidth = range / maxBucketCount;
|
|
let maxWidth = range / minBucketCount;
|
|
if (Number.isFinite(minWidth) && minWidth > 0)
|
|
{
|
|
bucketWidth = Math.max(bucketWidth, minWidth);
|
|
}
|
|
if (Number.isFinite(maxWidth) && maxWidth > 0)
|
|
{
|
|
bucketWidth = Math.min(bucketWidth, maxWidth);
|
|
}
|
|
|
|
bucketWidth = this.#SnapUpToNiceBucketWidth(bucketWidth, precisionStep);
|
|
return this.#FormatBucketSize(bucketWidth);
|
|
}
|
|
|
|
#GetFreedmanDiaconisWidth(sortedValueList)
|
|
{
|
|
let n = sortedValueList.length;
|
|
if (n < 2)
|
|
{
|
|
return NaN;
|
|
}
|
|
|
|
let q1 = this.#GetQuantile(sortedValueList, 0.25);
|
|
let q3 = this.#GetQuantile(sortedValueList, 0.75);
|
|
let iqr = q3 - q1;
|
|
if (!Number.isFinite(iqr) || iqr <= 0)
|
|
{
|
|
return NaN;
|
|
}
|
|
|
|
let width = (2 * iqr) / Math.cbrt(n);
|
|
return width > 0 ? width : NaN;
|
|
}
|
|
|
|
#GetQuantile(sortedValueList, p)
|
|
{
|
|
let count = sortedValueList.length;
|
|
if (count == 0)
|
|
{
|
|
return NaN;
|
|
}
|
|
|
|
if (count == 1)
|
|
{
|
|
return sortedValueList[0];
|
|
}
|
|
|
|
let idx = (count - 1) * p;
|
|
let lowerIdx = Math.floor(idx);
|
|
let upperIdx = Math.ceil(idx);
|
|
let lower = sortedValueList[lowerIdx];
|
|
let upper = sortedValueList[upperIdx];
|
|
let frac = idx - lowerIdx;
|
|
return lower + ((upper - lower) * frac);
|
|
}
|
|
|
|
#SnapUpToNiceBucketWidth(bucketWidth, baseStep)
|
|
{
|
|
if (!Number.isFinite(bucketWidth) || bucketWidth <= 0)
|
|
{
|
|
return baseStep > 0 ? baseStep : 1;
|
|
}
|
|
|
|
let unit = (Number.isFinite(baseStep) && baseStep > 0) ? baseStep : 1;
|
|
let ratio = Math.max(1, bucketWidth / unit);
|
|
let exp = Math.floor(Math.log10(ratio));
|
|
let scaled = ratio / (10 ** exp);
|
|
let niceMantissaList = [1, 2, 2.5, 5, 10];
|
|
let mantissa = niceMantissaList[niceMantissaList.length - 1];
|
|
|
|
for (const candidate of niceMantissaList)
|
|
{
|
|
if (scaled <= candidate)
|
|
{
|
|
mantissa = candidate;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return unit * mantissa * (10 ** exp);
|
|
}
|
|
|
|
#FormatBucketSize(bucketSize)
|
|
{
|
|
if (!Number.isFinite(bucketSize) || bucketSize <= 0)
|
|
{
|
|
return "1";
|
|
}
|
|
|
|
if (Math.abs(bucketSize) >= 1)
|
|
{
|
|
return String(Number(bucketSize.toFixed(12)));
|
|
}
|
|
|
|
let decimals = Math.min(12, Math.max(0, Math.ceil(-Math.log10(bucketSize)) + 2));
|
|
return String(Number(bucketSize.toFixed(decimals)));
|
|
}
|
|
|
|
NormalizeBucketSize()
|
|
{
|
|
let bucketSize = Number(this.guiState.bucketSize);
|
|
if (!Number.isFinite(bucketSize) || bucketSize <= 0)
|
|
{
|
|
bucketSize = Number(this.GetInitialBucketSizeForSeries(this.data.td, this.guiState.xSeries));
|
|
}
|
|
|
|
this.guiState.bucketSize = this.#FormatBucketSize(bucketSize);
|
|
}
|
|
|
|
NormalizeRangeBounds()
|
|
{
|
|
let normalize = (value) => {
|
|
if (value == undefined || value == null)
|
|
{
|
|
return "";
|
|
}
|
|
|
|
let text = String(value).trim();
|
|
if (text == "")
|
|
{
|
|
return "";
|
|
}
|
|
|
|
let num = Number(text);
|
|
if (!Number.isFinite(num))
|
|
{
|
|
return "";
|
|
}
|
|
|
|
return String(num);
|
|
};
|
|
|
|
this.guiState.minValue = normalize(this.guiState.minValue);
|
|
this.guiState.maxValue = normalize(this.guiState.maxValue);
|
|
|
|
if (this.guiState.minValue !== "" && this.guiState.maxValue !== "")
|
|
{
|
|
let minValue = Number(this.guiState.minValue);
|
|
let maxValue = Number(this.guiState.maxValue);
|
|
if (minValue > maxValue)
|
|
{
|
|
this.guiState.maxValue = this.guiState.minValue;
|
|
}
|
|
}
|
|
}
|
|
|
|
GetHistogramData()
|
|
{
|
|
if (!this.data || !this.data.td)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
let td = this.data.td;
|
|
let xSeries = this.guiState.xSeries;
|
|
let bucketSize = Number(this.guiState.bucketSize);
|
|
if (!Number.isFinite(bucketSize) || bucketSize <= 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
this.NormalizeRangeBounds();
|
|
let minLimit = this.guiState.minValue === "" ? null : Number(this.guiState.minValue);
|
|
let maxLimit = this.guiState.maxValue === "" ? null : Number(this.guiState.maxValue);
|
|
|
|
let valueList = [];
|
|
for (const row of td.ExtractDataOnly([xSeries]))
|
|
{
|
|
let value = Number(row[0]);
|
|
if (Number.isFinite(value))
|
|
{
|
|
if (minLimit !== null && value < minLimit)
|
|
{
|
|
continue;
|
|
}
|
|
if (maxLimit !== null && value > maxLimit)
|
|
{
|
|
continue;
|
|
}
|
|
valueList.push(value);
|
|
}
|
|
}
|
|
|
|
if (valueList.length == 0)
|
|
{
|
|
return {
|
|
bucketLabelList: [],
|
|
bucketCountList: [],
|
|
xAxisName: xSeries,
|
|
yAxisName: "Count",
|
|
};
|
|
}
|
|
|
|
let minValue = Math.min(...valueList);
|
|
let maxValue = Math.max(...valueList);
|
|
let start = Math.floor(minValue / bucketSize) * bucketSize;
|
|
let bucketCount = Math.max(1, Math.floor((maxValue - start) / bucketSize) + 1);
|
|
let bucketCountList = new Array(bucketCount).fill(0);
|
|
|
|
for (const value of valueList)
|
|
{
|
|
let idx = Math.floor((value - start) / bucketSize);
|
|
idx = Math.max(0, Math.min(bucketCount - 1, idx));
|
|
bucketCountList[idx] += 1;
|
|
}
|
|
|
|
let decimals = 0;
|
|
let bucketText = String(this.guiState.bucketSize);
|
|
if (bucketText.includes("."))
|
|
{
|
|
decimals = bucketText.length - bucketText.indexOf(".") - 1;
|
|
}
|
|
|
|
let formatBucketEdge = (value) => {
|
|
let rounded = Number(value.toFixed(Math.min(12, Math.max(0, decimals))));
|
|
return rounded.toLocaleString("en-US", {
|
|
minimumFractionDigits: decimals,
|
|
maximumFractionDigits: decimals,
|
|
});
|
|
};
|
|
|
|
let bucketLabelList = [];
|
|
for (let i = 0; i < bucketCount; ++i)
|
|
{
|
|
let low = start + (i * bucketSize);
|
|
let high = low + bucketSize;
|
|
bucketLabelList.push(`${formatBucketEdge(low)} to < ${formatBucketEdge(high)}`);
|
|
}
|
|
|
|
return {
|
|
bucketLabelList,
|
|
bucketCountList,
|
|
xAxisName: xSeries,
|
|
yAxisName: "Count",
|
|
grid: {
|
|
top: 30,
|
|
left: 48,
|
|
right: 18,
|
|
bottom: 82,
|
|
},
|
|
xAxisNameGap: 58,
|
|
xAxisLabelRotate: 30,
|
|
xAxisLabelMargin: 10,
|
|
yAxisNameGap: 12,
|
|
};
|
|
}
|
|
|
|
RenderHistogram()
|
|
{
|
|
if (!this.data || !this.data.td)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (this.gui)
|
|
{
|
|
this.#UpdateGuiLayout(this.data.colNameList || []);
|
|
}
|
|
|
|
this.NormalizeBucketSize();
|
|
let hist = this.GetHistogramData();
|
|
if (!hist)
|
|
{
|
|
return;
|
|
}
|
|
|
|
this.chart.setOption({
|
|
grid: hist.grid,
|
|
xAxis: {
|
|
type: "category",
|
|
data: hist.bucketLabelList,
|
|
name: hist.xAxisName,
|
|
nameLocation: "middle",
|
|
nameGap: hist.xAxisNameGap,
|
|
axisLabel: {
|
|
interval: 1,
|
|
rotate: hist.xAxisLabelRotate,
|
|
fontSize: 10,
|
|
margin: hist.xAxisLabelMargin,
|
|
showMaxLabel: true,
|
|
hideOverlap: false,
|
|
},
|
|
},
|
|
yAxis: {
|
|
type: "value",
|
|
name: hist.yAxisName,
|
|
min: 0,
|
|
minInterval: 1,
|
|
nameGap: hist.yAxisNameGap,
|
|
axisLabel: {
|
|
formatter: EChartsUtils.RoundCommas,
|
|
},
|
|
},
|
|
tooltip: {
|
|
trigger: "axis",
|
|
},
|
|
series: [
|
|
{
|
|
type: "bar",
|
|
data: hist.bucketCountList,
|
|
barMaxWidth: 24,
|
|
},
|
|
],
|
|
animation: false,
|
|
}, true);
|
|
}
|
|
}
|