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

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);
}
}