/* 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 = `
`; } msg += `
`; msg += `
`; 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])}
${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); } }