Source: charts/ChartUtil.js

/*
 * License information at https://github.com/Caltech-IPAC/firefly/blob/master/License.txt
 */


/**
 * Utilities related to charts
 * Created by tatianag on 3/17/16.
 */

import {
    assign, flatten, get, uniqueId, isArray, isEmpty, range, set, isObject, isString, pick, cloneDeep, merge, has,
    isUndefined
} from 'lodash';
import shallowequal from 'shallowequal';

import {getAppOptions} from '../core/AppDataCntlr.js';
import {getTblById, isFullyLoaded, isNumericType, watchTableChanges} from '../tables/TableUtil.js';
import {TABLE_HIGHLIGHT, TABLE_LOADED, TABLE_SELECT} from '../tables/TablesCntlr.js';
import {dispatchLoadTblStats} from './TableStatsCntlr.js';
import {dispatchChartUpdate, dispatchChartHighlighted, dispatchChartSelect, getChartData} from './ChartsCntlr.js';
import {Expression} from '../util/expr/Expression.js';
import {quoteNonAlphanumeric}  from '../util/expr/Variable.js';
import {flattenObject} from '../util/WebUtil.js';
import {SelectInfo} from '../tables/SelectInfo.js';
import {getTraceTSEntries as histogramTSGetter} from './dataTypes/FireflyHistogram.js';
import {getTraceTSEntries as heatmapTSGetter} from './dataTypes/FireflyHeatmap.js';
import {getTraceTSEntries as genericTSGetter} from './dataTypes/FireflyGenericData.js';
import Color from '../util/Color.js';
import {MetaConst} from '../data/MetaConst';
import {ALL_COLORSCALE_NAMES, colorscaleNameToVal} from './Colorscale.js';
import {findTableCenterColumns} from '../util/VOAnalyzer.js';

export const DEFAULT_ALPHA = 0.5;

export const SCATTER = 'scatter';
export const HEATMAP = 'heatmap';

export const SELECTED_COLOR = 'rgba(255, 200, 0, 1)';
export const SELECTED_PROPS = {
    name: '__SELECTED',
    marker: {color: SELECTED_COLOR},
    error_x: {color: SELECTED_COLOR},
    error_y: {color: SELECTED_COLOR}
};

export const HIGHLIGHTED_COLOR = 'rgba(255, 165, 0, 1)';
export const HIGHLIGHTED_PROPS = {
    name: '__HIGHLIGHTED',
    marker: {color: HIGHLIGHTED_COLOR, line: {width: 2, color: HIGHLIGHTED_COLOR}}, // increase highlight marker with line border
    error_x: {color: HIGHLIGHTED_COLOR},
    error_y: {color: HIGHLIGHTED_COLOR}
};

const FSIZE = 12;

export const TBL_SRC_PATTERN = /^tables::(.+)/;

/**
 * Does the application only support single trace
 * @returns {*} true, if all charts are single trace
 */
export function singleTraceUI() {
    return get(getAppOptions(), 'charts.singleTraceUI');
}

/**
 * Maximum table rows for scatter chart support, heatmap is created for larger tables
 * @returns {*}
 */
export function getMaxScatterRows() {
    return get(getAppOptions(), 'charts.maxRowsForScatter', 5000);
}


/**
 * For scatter charts, the minimum number of points to use 'scattergl' (Web GL);
 * If the number of points less than this, 'scatter' (SVG) is used.
 * Web GL and SVG traces can be displayed in the same chart.
 * @returns {*}
 */
export function getMinScatterGLRows() {
    return get(getAppOptions(), 'charts.minScatterGLRows', 1000);
}

/**
 * @summary Get unique chart id
 * @param {string} [prefix] - prefix
 * @returns {string} unique chart id
 * @public
 * @function uniqueChartId
 * @memberof firefly.util.chart
 */
export function uniqueChartId(prefix) {
    return uniqueId(prefix?prefix+'-c':'c');
}

export function colWithName(cols, name) {
    return cols.find((c) => { return c.name===name; });
}

export function getNumericCols(cols) {
    const ncols = [];
    const excludeNames = ['ROW_IDX', 'ROW_NUM'];
    cols.forEach((c) => {
        if (isNumericType(c)) {
            if (!excludeNames.includes(c.name)) { ncols.push(c); }
        }
    });
    return ncols;
}



/**
 * @global
 * @public
 * @typedef {Object} XYPlotOptions - shallow object with XYPlot parameters
 * @prop {string}  [source]     location of the ipac table, url or file path; ignored when XY plot view is added to table
 * @prop {string}  [tbl_id]     table id of the table this plot is connected to
 * @prop {string}  [chartTitle] title of the chart
 * @prop {string}  xCol         column or expression to use for x values, can contain multiple column names ex. log(col) or (col1-col2)/col3
 * @prop {string}  yCol         column or expression to use for y values, can contain multiple column names ex. sin(col) or (col1-col2)/col3
 * @prop {string}  [plotStyle]  points, linepoints, line
 * @prop {string}  [xLabel]     label to use with x axis
 * @prop {string}  [yLabel]     label to use with y axis
 * @prop {string}  [xOptions]   comma separated list of x axis options: grid,flip,log
 * @prop {string}  [yOptions]   comma separated list of y axis options: grid,flip,log
 * @prop {string}  [xError]     column or expression for X error
 * @prop {string}  [yError]     column or expression for Y error
 */

/**
 * @summary Convert shallow object with XYPlot parameters to scatter plot parameters object.
 * @param {XYPlotOptions} params - shallow object with XYPlot parameters
 * @returns {Object} - object, used to create Plotly chart
 * @public
 * @function makeXYPlotParams
 * @deprecated
 * @memberof firefly.util.chart
 */
export function makeXYPlotParams(params) {

    const {tbl_id, xCol, yCol, plotStyle, xError, yError, xLabel, yLabel, xOptions, yOptions, chartTitle} = params;

    let data, layout={xaxis:{}, yaxis:{showgrid: false}};

    if (xCol && yCol) {
        const trace = {tbl_id};
        trace.x = `tables::${xCol}`;
        trace.y = `tables::${yCol}`;
        if (xError) { trace.error_x = {array: `tables::${xError}`}; }
        if (yError) { trace.error_y = {array: `tables::${yError}`}; }
        trace.mode = 'markers';
        if (plotStyle) {
            if (plotStyle === 'line') {
                trace.mode = 'lines';
            } else if (plotStyle === 'linepoints') {
                trace.mode = 'lines+markers';
            } 
        }
        data = [trace];
    } else {
        const defProps = getDefaultChartProps(tbl_id) || {};
        if (!isObject(defProps)) { return; }
        data = defProps.data;
        layout = Object.assign(layout, defProps.layout);
    }

    if (xLabel) { layout.xaxis.title = xLabel; }
    if (yLabel) { layout.yaxis.title = yLabel; }
    if (xOptions) {
        if (xOptions.includes('grid')) { layout.xaxis.showgrid  = true; }
        if (xOptions.includes('flip')) { layout.xaxis.autorange  = 'reversed'; }
        if (xOptions.includes('log')) { layout.xaxis.type  = 'log'; }
    }

    if (yOptions) {
        if (yOptions.includes('grid')) { layout.yaxis.showgrid  = true; }
        if (yOptions.includes('flip')) { layout.yaxis.autorange  = 'reversed'; }
        if (yOptions.includes('log')) { layout.yaxis.type  = 'log'; }
    }
    if (chartTitle) { layout.title = chartTitle; }
    return {data, layout};
}


/**
 * @global
 * @public
 * @typedef {Object} HistogramOptions
 * @summary shallow object with histogram parameters
 * @prop {string}  [source]     location of the ipac table, url or file path; ignored when histogram view is added to table
 * @prop {string}  [tbl_id]     table id of the table this plot is connected to
 * @prop {string}  [chartTitle] title of the chart
 * @prop {string}  col          column or expression to use for histogram, can contain multiple column names ex. log(col) or (col1-col2)/col3
 * @prop {number}  [numBins=50] number of bins for fixed bins algorithm (default)
 * @prop {number}  [falsePositiveRate] false positive rate for bayesian blocks algorithm
 * @prop {string}  [xOptions]   comma separated list of x axis options: flip,log
 * @prop {string}  [yOptions]   comma separated list of y axis options: flip,log
 */

/**
 * @summary Convert shallow object with Histogram parameters to histogram plot parameters object.
 * @param {HistogramOptions} params - shallow object with Histogram parameters
 * @returns {HistogramParams} - object, used to create Histogram chart
 * @public
 * @function makeHistogramParams
 * @deprecated
 * @memberof firefly.util.chart
 */
export function makeHistogramParams(params) {
    const {tbl_id, col, xOptions, yOptions, falsePositiveRate, binWidth} = params;
    let numBins = params.numBins;
    let fixedBinSizeSelection = params.fixedBinSizeSelection;
    if (!falsePositiveRate) {
        if (!fixedBinSizeSelection) {
            fixedBinSizeSelection = 'numBins';
        }
        if (!numBins) {
            numBins = 50;
        }
    }
    const algorithm = numBins ? 'fixedSizeBins' : 'bayesianBlocks';

    if (col) {
        const options = {
            columnOrExpr: col,
            algorithm,
            fixedBinSizeSelection,
            numBins,
            binWidth,
            falsePositiveRate
        };
        const layout = {xaxis: {}, yaxis: {}};
        if (xOptions) {
            if (xOptions.includes('flip')) { layout.xaxis.autorange  = 'reversed'; }
            if (xOptions.includes('log')) { layout.xaxis.type  = 'log'; }
        }
        if (yOptions) {
            if (yOptions.includes('flip')) { layout.yaxis.autorange  = 'reversed'; }
            if (yOptions.includes('log')) { layout.yaxis.type  = 'log'; }
        }

        return {
            data : [{
                type: 'fireflyHistogram',
                firefly: {
                    tbl_id,
                    options
                }
            }],
            layout
        };
    }
}

/**
 * For a given plotly point index, get rowIdx connecting a given plotly point to the table row.
 * @param traceData
 * @param pointIdx
 * @returns {number}
 */
export function getRowIdx(traceData, pointIdx) {
    // firefly.rowIdx array in the trace data connects plotly points to table row indexes
    return Number(get(traceData, `firefly.rowIdx.${pointIdx}`, pointIdx));
}

/**
 * For a given table row index, get plotly point index
 * @param traceData
 * @param rowIdx
 * @returns {number}
 */
export function getPointIdx(traceData, rowIdx) {
    const rowIdxArray = get(traceData, 'firefly.rowIdx');
    // use double equal in case we compare string to Number
    return rowIdxArray ? rowIdxArray.findIndex((e) => e == rowIdx) : rowIdx;
}

export function isScatter2d(type) {
    return type.includes('scatter') && !type.endsWith('3d');
}


export function clearChartConn({chartId}) {
    const oldTablesources = get(getChartData(chartId), 'tablesources',[]);
    if (Array.isArray(oldTablesources)) {
        oldTablesources.forEach( (traceTS) => {
            if (traceTS._cancel) {
                traceTS._cancel();
                traceTS._cancel = undefined;
            }   // cancel the previous watcher if exists
        });
    }
}

export function newTraceFrom(data, selIndexes, newTraceProps, traceAnnotations) {

    const sdata = cloneDeep(pick(data, ['x', 'y', 'z', 'legendgroup', 'error_x', 'error_y', 'text', 'hovertext', 'marker', 'hoverinfo', 'firefly' ]));
    Object.assign(sdata, {showlegend: false, type: get(data, 'type', 'scatter'), mode: 'markers'});

    // the rowIdx doesn't exist for generic plotly chart case
    if (isScatter2d(get(data, 'type', '')) &&
        !get(sdata, 'firefly.rowIdx') &&
        get(sdata, 'x.length', 0) !== 0) {
        const rowIdx = range(get(sdata, 'x.length')).map(String);
        set(sdata, 'firefly.rowIdx', rowIdx);
    }

    if (isArray(traceAnnotations) && traceAnnotations.length > 0) {
        const annotations = cloneDeep(traceAnnotations);
        const color = get(newTraceProps, 'marker.color');

        flattenAnnotations(annotations).forEach((a) => {a && (a.arrowcolor = color);});
        set(sdata, 'firefly.annotations', annotations);
    }

    // walk through object and replace values where there's an array with only the selected indexes.
    function deepReplace(obj) {
        Object.entries(obj).forEach( ([k,v]) => {
            if (Array.isArray(v) && v.length > selIndexes.length) {
                obj[k] = selIndexes.reduce((p, sIdx) => {
                    p.push(v[sIdx]);
                    return p;
                }, []);
            } else if (isObject(v)) {
                deepReplace(v);
            }
        });
    }
    deepReplace(sdata);
    const flatprops = flattenObject(newTraceProps);
    Object.entries(flatprops).forEach(([k,v]) => set(sdata, k, v));
    return sdata;
}

/**
 * Get trace annotation as a one level deep array
 * @param {array} annotations - trace annotations (there could be none, a single, or an array of annotations per point
 */
export function flattenAnnotations(annotations) {
    if (isArray(annotations)) {
        const filtered = annotations.filter((e) => !isUndefined(e));
        if (filtered.length > 0) {
            // trace annotations can have a single annotation or an array of annotations per point
            return flatten(filtered);
        }
    }
    return [];
}

export function updateSelected(chartId, selectInfo) {
    const selectInfoCls = SelectInfo.newInstance(selectInfo);
    const {data, activeTrace=0} = getChartData(chartId);
    const traceData = data[activeTrace];
    const selIndexes = Array.from(selectInfoCls.getSelected()).map((e)=>getPointIdx(traceData, e));
    if (selIndexes) {
        dispatchChartSelect({chartId, selIndexes});
    }
}

export function updateHighlighted(chartId, traceNum, highlightedRow) {
    const traceData = get(getChartData(chartId), `data.${traceNum}`);
    dispatchChartHighlighted({chartId, highlighted: getPointIdx(traceData,highlightedRow)});
}

export function getDataChangesForMappings({tableModel, mappings, traceNum}) {

    let getDataVal;
    const changes = {};
    changes[`fireflyData.${traceNum}.isLoading`] = !Boolean(tableModel);
    if (tableModel) {
        const cols = tableModel.tableData.columns.map((c) => c.name);
        const transposed = tableModel.tableData.columns.map(() => []);
        tableModel.tableData.data.forEach((r) => {
            r.map((e, idx) => transposed[idx].push(e));
        });
        // tableModel columns are named as the paths to the trace arrays
        getDataVal = (k,v) => {
            // using plotly attribute path (key in the mappings object) as a column name
            // this makes it possible to use the same column as x and y, for example
            let idx = cols.indexOf(v);
            if (idx < 0) idx = cols.indexOf(k);
            if (idx >= 0) {
                return transposed[idx];
            } else {
                // if value is a numeric constant,
                // we might be able to use it instead of array
                // example: marker.size can be a number or an array
                const numericConstant = parseFloat(v);
                if (!Number.isNaN(numericConstant)) {
                    return numericConstant;
                }
            }
        };
    } else {
        // no tableModel case is for pre-fetch changes
        changes[`fireflyData.${traceNum}.error`] = undefined;
        getDataVal = (k,v) => v;
    }

    if (mappings) {
        Object.entries(mappings).forEach(([k,v]) => {
            const key = k.startsWith('firefly') ? k : `data.${traceNum}.${k}`;
            changes[key] = getDataVal(key,v);
        });
    }

    return changes;
}

/**
 *
 * @param {object} p
 * @param {string} p.chartId
 * @param {object[]} p.data
 */
export function handleTableSourceConnections({chartId, data, fireflyData}) {
    const tablesources = makeTableSources(chartId, data, fireflyData);
    const {tablesources:oldTablesources=[], activeTrace, curveNumberMap=[]} = getChartData(chartId);

    const hasTablesources = Array.isArray(tablesources) && tablesources.find((ts) => !isEmpty(ts));
    if (!hasTablesources) return;

    const numTraces = Math.max(tablesources.length, oldTablesources.length);
    range(numTraces).forEach( (idx) => {  // range instead of for-loop is to avoid the idx+1 JS's closure problem
        let traceTS = tablesources[idx];
        const oldTraceTS = oldTablesources[idx] || {};

        let doUpdate = false;
        if (isEmpty(traceTS)) {
            // no updates to this trace, but shared layout updates might affect this trace
            const {fireflyData:oldFireflyData} = getChartData(chartId);
            const chartDataType = get(oldFireflyData[idx], 'dataType');
            traceTS = Object.assign({}, oldTraceTS, getTraceTSEntries({chartDataType, traceTS: oldTraceTS, chartId, traceNum: idx}));
        } else {
            // if mappings are resolved, we need to get info from old tablesource
            if (!traceTS.fetchData) {
                traceTS = Object.assign({}, oldTraceTS, traceTS);
            }
        }

        if (!tablesourcesEqual(traceTS, oldTraceTS)) {
            if (oldTraceTS && oldTraceTS._cancel) {
                oldTraceTS._cancel(); // cancel the previous watcher if exists
                oldTraceTS._cancel = undefined;
                traceTS._cancel = undefined;
            }
            doUpdate = true;
        } else {
            traceTS = oldTraceTS;
        }

        // make sure table watcher is set for all non-empty table sources
        if (!isEmpty(traceTS) && !traceTS._cancel) {
            //creates a new one.. and save the cancel handle
            if (doUpdate) {
                // fetch data syncs highlighted and selected with the table
                updateChartData(chartId, idx, traceTS);
            } else {
                if (idx === activeTrace && isFullyLoaded(traceTS.tbl_id)) {
                    // update highlighted and selected
                    const tableModel = getTblById(traceTS.tbl_id);
                    const {highlightedRow, selectInfo={}} = tableModel;
                    updateHighlighted(chartId, idx, highlightedRow);
                    updateSelected(chartId, selectInfo);
                }
            }
            traceTS._cancel = setupTableWatcher(chartId, traceTS, idx);
        }
        tablesources[idx] = traceTS;
    });

    const changes = {tablesources};

    // update curveNumberMap if it does not contain all the traces
    if (curveNumberMap.length < tablesources.length) {
        const curveMap = range(tablesources.length).filter((idx) => (idx !== activeTrace));
        curveMap.push(activeTrace);
        changes['curveNumberMap'] = curveMap;
    }

    dispatchChartUpdate({chartId, changes});
}

export function setupTableWatcher(chartId, ts, idx) {
    return watchTableChanges(ts.tbl_id,
        [TABLE_LOADED, TABLE_HIGHLIGHT, TABLE_SELECT],
        (action) => updateChartData(chartId, idx, ts, action),
        uniqueId(`ucd-${ts.tbl_id}-trace`)); // watcher id for debugging
}

function tablesourcesEqual(newTS, oldTS) {

    // checking if the table or mappings options have changed
    // or table watcher has been cancelled
    return get(newTS, 'tbl_id') === get(oldTS, 'tbl_id') &&
        get(newTS, 'resultSetID') === get(oldTS, 'resultSetID') &&
        shallowequal(get(newTS, 'mappings'), get(oldTS, 'mappings')) &&
        shallowequal(get(newTS, 'options'), get(oldTS, 'options'));
}


function updateChartData(chartId, traceNum, tablesource, action={}) {
    // make sure the chart is not yet removed
    if (isEmpty(getChartData(chartId))) { return; }
    const {tbl_id, resultSetID, mappings} = tablesource;
    if (action.type === TABLE_HIGHLIGHT) {
        // ignore if traceNum is not active
        const {activeTrace=0} = getChartData(chartId);
        if (traceNum !== activeTrace) return;
        const {highlightedRow} = action.payload;
        updateHighlighted(chartId, traceNum, highlightedRow);
    } else if (action.type === TABLE_SELECT) {
        const {activeTrace=0} = getChartData(chartId);
        if (traceNum !== activeTrace) return;
        const {selectInfo={}} = action.payload;
        updateSelected(chartId, selectInfo);
    } else {
        if (!isFullyLoaded(tbl_id)) return;
        const tableModel = getTblById(tbl_id);
        dispatchLoadTblStats(tableModel.request);

        const changes = getDataChangesForMappings({mappings, traceNum});

        // save original table file path
        const resultSetIDNow = get(tableModel, 'tableMeta.resultSetID');
        if (resultSetIDNow !== resultSetID) {
            changes[`tablesources.${traceNum}.resultSetID`] = resultSetIDNow;
        }
        if (!isEmpty(changes)) {
            dispatchChartUpdate({chartId, changes});
        }

        // fetch data for both Firefly recognized or unrecognized plotly chart types
        if (tablesource.fetchData) {
            tablesource.fetchData(chartId, traceNum, tablesource);
        }
    }
}



function makeTableSources(chartId, data=[], fireflyData=[]) {

    const convertToDS = (flattenData) =>
                        Object.entries(flattenData)
                                .filter(([,v]) => typeof v === 'string' && v.startsWith('tables::'))
                                .reduce( (p, [k,v]) => {
                                    const [,colExp] = v.match(TBL_SRC_PATTERN) || [];
                                    if (colExp) set(p, ['mappings',k], colExp);
                                    return p;
                                }, {});


    // for some firefly specific chart types the data are
    const currentData = (data.length < fireflyData.length) ? fireflyData : data;

    return currentData.map((d, traceNum) => {
        const fireflyDataFlatten = flattenObject(fireflyData[traceNum] || {}, `fireflyData.${traceNum}`);
        // fireflyData mappings have full path
        const flattenData = assign(flattenObject(data[traceNum] || {}), fireflyDataFlatten);
        const ds = data[traceNum] ? convertToDS(flattenData) : {}; //avoid flattening arrays
        // table id can be a part of fireflyData too
        const tbl_id = get(data, `${traceNum}.tbl_id`) || get(fireflyData, `${traceNum}.tbl_id`);

        if (tbl_id) ds.tbl_id = tbl_id;

        // we use resultSetID to see if the table has changed (sorted, filtered, etc.)
        if (ds.tbl_id) {
            const tableModel = getTblById(ds.tbl_id);
            ds.resultSetID = get(tableModel, 'tableMeta.resultSetID');
        }
        // set up table server request parameters (options) for firefly specific charts
        const chartDataType = get(fireflyData[traceNum], 'dataType');
        if (!isEmpty(ds)) {
            Object.assign(ds, getTraceTSEntries({chartDataType, traceTS: ds, chartId, traceNum}));
        }
        return ds;
    });
}

function getTraceTSEntries({chartDataType, traceTS, chartId, traceNum}) {
    if (chartDataType === 'fireflyHistogram') {
        return histogramTSGetter({traceTS, chartId, traceNum});
    } else if (chartDataType === 'fireflyHeatmap') {
        return heatmapTSGetter({traceTS, chartId, traceNum});
    } else {
        return genericTSGetter({traceTS, chartId, traceNum});
    }
}

// does the default depend on the chart type?
/**
 * set default value for layout and data
 * @param chartData
 * @param resetColor reset color generator for default color assignment of the chart
 */
export function applyDefaults(chartData={}, resetColor = true) {

    const nonPieChart = isEmpty(chartData) || !has(chartData, 'data') || chartData.data.find((d) => get(d, 'type') !== 'pie');
    const noXYAxis = Boolean(!nonPieChart);

    const chartType = get(chartData, ['data', '0', 'type']);
    const hMode = chartType ==='fireflyHistogram'? 'x':'closest';
    const defaultLayout = {
        hovermode: hMode,
        legend: {
            font: {size: FSIZE},
            orientation: 'v',
            yanchor: 'top'
        },
        xaxis: {
            autorange:true,
            showgrid: false,
            lineColor: '#e9e9e9',
            tickwidth: 1,
            ticklen: 5,
            titlefont: {
                size: FSIZE
            },
            ticks: noXYAxis ? '' : 'outside',
            showline: !noXYAxis,
            showticklabels: !noXYAxis,
            zeroline: false,
            exponentformat:'e'
        },
        yaxis: {
            autorange:true,
            showgrid: !noXYAxis,
            lineColor: '#e9e9e9',
            tickwidth: 1,
            ticklen: 5,
            titlefont: {
                size: FSIZE
            },
            ticks: noXYAxis ? '' : 'outside',
            showline: !noXYAxis,
            showticklabels: !noXYAxis,
            zeroline: false,
            exponentformat:'e'
        }
    };

    chartData.layout = merge(defaultLayout, chartData.layout);

    chartData.data && chartData.data.forEach((d, idx) => {
        d.name = defaultTraceName(d, idx, '');

        const type = get(chartData, ['fireflyData', `${idx}.dataType`]) || get(d, 'type', 'scatter');

        if (idx === 0 && resetColor) {        // reset the color iterator
            getNextTraceColor(true);
            getNextTraceColorscale(true);
        }

        type && Object.entries(getDefaultColorAttributes(d, type, idx)).forEach(([k, v]) => set(chartData, k, v));

        // default dragmode is select if box selection is supported
        type && !chartData.layout.dragmode && (chartData.layout.dragmode = isBoxSelectionSupported(type) ? 'select' : 'zoom');
    });
}

export function isBoxSelectionSupported(type) {
    if (!type || !isString(type)) return false;
    return ['heatmap', 'histogram2dcontour', 'histogram2d', 'scatter'].find((e) => type.toLowerCase().includes(e));
}

/**
 * Convert column expression to internal (database) syntax
 * @param {object} p
 * @param {string} p.colOrExpr - column expression
 * @param {boolean} p.quoted - if true, quote variable names
 * @param {string[]} p.colNames - valid column names
 * @returns {*}
 */
export function formatColExpr({colOrExpr, quoted, colNames}) {

    if (!colOrExpr) return;

    // do not change the expression, if it's matching a column name
    if (colNames && colNames.find((c) => c === colOrExpr)) {
        return quoted ? `"${colOrExpr}"` : colOrExpr;
    }

    const expr = new Expression(colOrExpr, colNames);
    if (expr.isValid()) {
        // remove white space
        colOrExpr = expr.getCanonicalInput();

        if (quoted) {
            // quote columns, assuming column names are alpha-numeric
            expr.getParsedVariables().forEach((v) => {
                if (!v.startsWith('"')) {
                    const re = new RegExp('([^A-Za-z\d_"]|^)(' + v + ')([^A-Za-z\d_"]|$)', 'g');
                    while (colOrExpr.match(re)) { // while is needed to handle cases like v*v
                        colOrExpr = colOrExpr.replace(re, '$1"$2"$3'); // add quotes
                    }
                }
            });
            colOrExpr = colOrExpr.replace(/"NULL"/g, 'NULL'); // unquote NULL
        }
    }

    return colOrExpr;
}


// plotly default color (items 0-7) + color-blind friendly colors
export const TRACE_COLORS = [  '#1f77b4', '#2ca02c', '#d62728', '#9467bd',
                               '#8c564b', '#e377c2', '#7f7f7f', '#17becf',
                               '#333333', '#ff3333', '#00ccff', '#336600',
                               '#9900cc', '#ff9933', '#009999', '#66ff33',
                               '#cc9999', '#b22424', '#008fb2', '#244700',
                               '#6b008f', '#b26b24', '#006b6b', '#47b224', '#8F6B6B'];
export const TRACE_COLORSCALE = ALL_COLORSCALE_NAMES;

export function toRGBA(c, alpha) {
    if (!alpha) { alpha = DEFAULT_ALPHA; }
    const [r, g, b, a] = Color.toRGBA(c, alpha);
    return `rgba(${r},${g},${b},${a})`;
}

export function *traceColorGenerator(colorList, isColor) {
    let nextIdx = -1;

    const f = (c) => isColor ? toRGBA(c, DEFAULT_ALPHA) : c;

    while (true) {
        const result = yield (nextIdx === -1) ? '' : f(colorList[nextIdx%colorList.length]);
        result ? nextIdx = -1 : nextIdx++;
    }
}

const nextTraceColor = traceColorGenerator(TRACE_COLORS, true);
const nextTraceColorscale = traceColorGenerator(TRACE_COLORSCALE);
const getNextTraceColor = (b) => nextTraceColor.next(b).value;
const getNextTraceColorscale = (b) => nextTraceColorscale.next(b).value;

/**
 * This function returns default attributes for a new trace (via UI).
 * @param chartId
 * @param type
 * @param traceNum - an object with the default color attributes for a trace
 * @returns {*}
 */
export function getNewTraceDefaults(chartId, type='', traceNum=0) {
    let   retV;

    if (type.includes(SCATTER)) {
        // we only need to set marker color: other color attributes will be set based on marker color
        // this is handled by BasicOptions, which keeps all color attributes in sync with marker color
        const traceColor = defaultTraceColor({}, traceNum, chartId);
        retV = {
            [`data.${traceNum}.type`]: type, //make sure trace type is set
            [`data.${traceNum}.marker.color`]: traceColor,
            [`data.${traceNum}.marker.line`]: 'none',
            [`data.${traceNum}.showlegend`]: true,
            ['layout.xaxis.range']: undefined, //clear out fixed range
            ['layout.yaxis.range']: undefined //clear out fixed range
        };
        colorsOnTypes['scatter'][0].forEach((p) => { retV[`data.${traceNum}.${p}`] = traceColor; } );
    } else if (type.toLowerCase().includes(HEATMAP)) {
        retV = {
            [`data.${traceNum}.showlegend`]: true,
            ['layout.xaxis.range']: undefined, //clear out fixed range
            ['layout.yaxis.range']: undefined //clear out fixed range
        };
        /*
            There are two approaches regarding unset color scale: pick a new one for every trace or
            keep it unset to let Plotly choose the best one for the data.
         */
        // const traceColorscale = TRACE_COLORSCALE[traceNum % TRACE_COLORSCALE.length];
        // const colorscaleVal = colorscaleNameToVal(traceColorscale);
        // if (colorscaleVal) {
        //     retV[`data.${traceNum}.colorscale`] = colorscaleVal;
        // }
        // if (colorscaleVal !== traceColorscale) {
        //     retV[`fireflyData.${traceNum}.colorscale`] = traceColorscale;
        // }
    } else {
        retV = {
            [`data.${traceNum}.marker.color`]: defaultTraceColor({}, traceNum, chartId),
            [`data.${traceNum}.showlegend`]: true
        };
    }
    retV[`data.${traceNum}.name`] = defaultTraceName({}, traceNum, chartId);

    return retV;
}

function defaultTraceName(oneChartData, idx, chartId) {
    let name = get(oneChartData, 'name');
    if (name) { return name; }
    else {
        // make sure that the name is unique
        const {data=[]} = getChartData(chartId);
        let i = idx;
        let unique = false;
        while (!unique) {
            name = `trace ${i}`;
            // make sure the trace name is unique
            if (data.findIndex((d) => (get(d, 'name') === name)) < 0) {
                unique = true;
            }
            i++;
        }
        return name;
    }
}

function defaultTraceColor(oneChartData, idx, chartId) {
    let color = get(oneChartData, 'marker.color');
    if (color) { return color; }
    else {
        // make sure the color is unique
        const {data=[]} = getChartData(chartId);
        let i = idx;
        let unique = false;
        while (!unique) {
            color = toRGBA(TRACE_COLORS[i % TRACE_COLORS.length]);
            // make sure the trace name is unique
            if (data.findIndex((d) => (get(d, 'marker.color') === color)) < 0) {
                unique = true;
            }
            i++;
        }
        return color;
    }
}

export const colorsOnTypes = {
    bar: [['marker.color', 'error_x.color', 'error_y.color']],
    fireflyHistogram: [['marker.color']],
    box: [['marker.color', 'line.color']],
    heatmap: [['colorscale']],
    fireflyHeatmap: [['colorscale']],
    histogram: [['marker.color', 'error_x.color', 'error_y.color']],
    histogram2d: [['colorscale']],
    area: [['marker.color']],
    contour: [['colorscale' ]],
    histogram2dcontour: [['colorscale']],
    surface: [['colorscale']],
    mesh3d: [['colorscale']],
    chororpleth:  [['colorscale']],
    scatter: [['marker.color', 'line.color', 'textfont.color', 'error_x.color', 'error_y.color']],
    scatter3d: [['marker.color', 'line.color', 'textfont.color', 'error_x.color', 'error_y.color']],
    scattergl: [['marker.color', 'line.color',  'textfont.color', 'error_x.color', 'error_y.color']],
    scattergeo: [['marker.color', 'line.color', 'textfont.color']],
    others: [['marker.color']]
};

/**
 * This function is used to apply color attributes to a new chart
 * @param traceData
 * @param type
 * @param idx
 * @returns {{}} an object with color attributes
 */
function getDefaultColorAttributes(traceData, type, idx) {
    if (!type) return {};
    const colorSettingObj = {};

    const colorAttributes = Object.keys(colorsOnTypes).includes(type) ? colorsOnTypes[type] : colorsOnTypes.others;
    const color = getNextTraceColor();
    colorAttributes[0].filter((att) => att.endsWith('color')).forEach((att) => {
        if (!get(traceData, att)) {
            colorSettingObj[`data.${idx}.${att}`] = color;
        }
    });
    /*
        There are two approaches regarding unset color scale: pick a new one for every trace or
        keep it unset to let Plotly choose the best one for the data.
     */
    // const colorscaleName = getNextTraceColorscale();
    // const colorscaleVal =  colorscaleNameToVal(colorscaleName);
    // colorAttributes[0].filter((att) => att.endsWith('colorscale')).forEach((att) => {
    //     if (!get(traceData, att)) {
    //         if (colorscaleVal) {
    //             colorSettingObj[`data.${idx}.${att}`] = colorscaleVal;
    //         }
    //         if (colorscaleName !== colorscaleVal) {
    //             colorSettingObj[`fireflyData.${idx}.${att}`] = colorscaleName;
    //         }
    //     }
    // });
    return colorSettingObj;
}


export function getDefaultChartProps(tbl_id) {

    if (!isFullyLoaded(tbl_id)) { return; }

    const tblModel = getTblById(tbl_id);
    const {tableMeta, tableData, totalRows}= getTblById(tbl_id);

    if (!totalRows) {
        return;
    }

    // default chart props can be set in a table attribute
    const defaultChartDef = tableMeta[MetaConst.DEFAULT_CHART_DEF];
    const defaultChartProps = defaultChartDef && JSON.parse(defaultChartDef);
    if (defaultChartProps && defaultChartProps.data) {
        defaultChartProps.data.forEach((e) => e['tbl_id'] = tbl_id);
        return defaultChartProps;
    }

    // for catalogs use lon and lat columns
    const centerColumns = findTableCenterColumns(tblModel);
    let isCatalog = get(tblModel, 'totalRows') && centerColumns;
    let xCol = undefined, yCol = undefined;

    if (isCatalog) {
        xCol = colWithName(tableData.columns, get(centerColumns, 'lonCol'));
        yCol = colWithName(tableData.columns, get(centerColumns, 'latCol'));
        if (!xCol || !yCol) {
            isCatalog = false;
        }
    }

    //otherwise use the first one-two numeric columns
    if (!isCatalog) {
        const numericCols = getNumericCols(tableData.columns);
        if (numericCols.length > 1) {
            xCol = numericCols[0];
            yCol = numericCols[1];
        } else if (numericCols.length > 0) {
            xCol = numericCols[0];
            yCol = numericCols[0];
        }
    }

    if (xCol && yCol)  {
        // non-alphanumeric column names should be quoted in expressions
        const xColName = quoteNonAlphanumeric(xCol.name);
        const yColName = quoteNonAlphanumeric(yCol.name);
        
        if (xColName === yColName) {
            // if only one numeric column is available, do histogram
            const chartData = {
                data: [{
                    type: 'fireflyHistogram',
                    firefly: {
                        tbl_id,
                        options: {
                            algorithm: 'fixedSizeBins',
                            fixedBinSizeSelection: 'numBins',
                            numBins: 20,
                            columnOrExpr: `${xColName}`
                        }
                    },
                    name: `${xColName}`
                }]
            };
            return chartData;
        } else {
            // scatter that converts into heatmap and back depending on the number of points
            const colorscaleName = 'GreySeq';
            const colorscale = colorscaleNameToVal(colorscaleName);
            const chartData = {
                data: [{
                    tbl_id,
                    type: totalRows >= getMinScatterGLRows() ? 'scattergl' : 'scatter',
                    mode: 'markers',
                    x: xCol && `tables::${xColName}`,
                    y: yCol && `tables::${yColName}`,
                    colorscale,
                    firefly: {
                        scatterOrHeatmap: true,
                        colorscale: colorscaleName
                    }
                }],
                layout: {
                    xaxis: {
                        autorange: isCatalog ? 'reversed' : 'true'
                    },
                    yaxis: {showgrid: false}
                }
            };
            return chartData;
        }
    } else {
        return {};
    }
}