/* * License information at https://github.com/Caltech-IPAC/firefly/blob/master/License.txt */ import {cloneDeep, has, get, isArray, isEmpty, isString, isUndefined, omit, omitBy, set, range} from 'lodash'; import shallowequal from 'shallowequal'; import {flux} from '../Firefly.js'; import {updateSet, updateObject, toBoolean} from '../util/WebUtil.js'; import {getTblById, getColumns, isFullyLoaded, COL_TYPE} from '../tables/TableUtil.js'; import {dispatchAddActionWatcher} from '../core/MasterSaga.js'; import * as TablesCntlr from '../tables/TablesCntlr.js'; import {logError} from '../util/WebUtil.js'; import {DEFAULT_PLOT2D_VIEWER_ID, dispatchAddViewerItems, dispatchUpdateCustom, dispatchRemoveViewerItems, getMultiViewRoot, getViewer} from '../visualize/MultiViewCntlr.js'; import {applyDefaults, flattenAnnotations, formatColExpr, getPointIdx, getRowIdx, handleTableSourceConnections, clearChartConn, newTraceFrom, setupTableWatcher, HIGHLIGHTED_PROPS, SELECTED_PROPS, TBL_SRC_PATTERN} from './ChartUtil.js'; import {FilterInfo} from '../tables/FilterInfo.js'; import {SelectInfo} from '../tables/SelectInfo.js'; import {REINIT_APP, getAppOptions} from '../core/AppDataCntlr.js'; import {makeHistogramParams, makeXYPlotParams} from './ChartUtil.js'; import {adjustColorbars, hasFireflyColorbar} from './dataTypes/FireflyHeatmap.js'; export const CHART_SPACE_PATH = 'charts'; export const UI_PREFIX = `${CHART_SPACE_PATH}.ui`; export const DATA_PREFIX = `${CHART_SPACE_PATH}.data`; export const usePlotlyReact = toBoolean(sessionStorage.getItem('plotlyReact')); // defaults to false export const useScatterGL = toBoolean(sessionStorage.getItem('scatterGL')); // defaults to false export const useChartRedraw = toBoolean(sessionStorage.getItem('chartRedraw')); // defaults to false /*---------------------------- ACTIONS -----------------------------*/ export const CHART_ADD = `${DATA_PREFIX}/chartAdd`; export const CHART_UPDATE = `${DATA_PREFIX}/chartUpdate`; export const CHART_REMOVE = `${DATA_PREFIX}/chartRemove`; export const CHART_TRACE_REMOVE = `${DATA_PREFIX}/chartTraceRemove`; export const CHART_HIGHLIGHT = `${DATA_PREFIX}/chartHighlight`; export const CHART_SELECT = `${DATA_PREFIX}/chartSelectSelection`; export const CHART_FILTER_SELECTION = `${DATA_PREFIX}/chartFilterSelection`; export const CHART_SET_ACTIVE_TRACE = `${DATA_PREFIX}/chartSetActiveTrace`; export const CHART_UI_EXPANDED = `${UI_PREFIX}.expanded`; export const CHART_MOUNTED = `${UI_PREFIX}/mounted`; export const CHART_UNMOUNTED = `${UI_PREFIX}/unmounted`; const FIREFLY_TRACE_TYPES = ['scatter', 'scattergl', 'fireflyHistogram', 'fireflyHeatmap']; const EMPTY_ARRAY = []; export default {actionCreators, reducers}; const isDebug = () => get(window, 'firefly.debug', false); let cleanupWatcherStarted = false; function actionCreators() { return { [CHART_ADD]: chartAdd, [CHART_REMOVE]: chartRemove, [CHART_TRACE_REMOVE]: chartTraceRemove, [CHART_UPDATE]: chartUpdate, [CHART_HIGHLIGHT]: chartHighlight, [CHART_FILTER_SELECTION]: chartFilterSelection, [CHART_SELECT]: chartSelect, [CHART_SET_ACTIVE_TRACE]: setActiveTrace }; } function reducers() { return { [CHART_SPACE_PATH]: reducer }; } /* * Add chart to the UI * * @param {Object} p - dispatch parameters * @param {string} p.chartId - chart id * @param {string} p.chartType - (for backward compatibility) chart type, ex. 'scatter', 'histogram' * @param {string} [p.groupId] - chart group for grouping charts together * @param {string} [p.viewerId] – viewer where chart will be displayed * @param {boolean} [p.deletable] - is the chart deletable, if undefined: single chart in a group is not deletable, multiple are deletable * @param {string} [p.help_id] - help id, if undefined, no help icon shows up * @param {Function} [p.dispatcher=flux.process] - only for special dispatching uses such as remote * @public * @function dispatchChartAdd * @memberof firefly.action */ export function dispatchChartAdd({chartId, chartType='plot.ly', groupId='main', viewerId=DEFAULT_PLOT2D_VIEWER_ID, deletable, help_id, mounted=undefined, dispatcher= flux.process, ...rest}) { dispatcher({type: CHART_ADD, payload: {chartId, chartType, groupId, viewerId, deletable, help_id, mounted, ...rest}}); } /* * Delete chart and related data * @param {string} chartId - chart id * @param {Function} [dispatcher=flux.process] - only for special dispatching uses such as remote * @public * @function dispatchChartRemove * @memberof firefly.action */ export function dispatchChartRemove(chartId, dispatcher= flux.process) { dispatcher({type: CHART_REMOVE, payload: {chartId}}); } /* * Delete chart trace and the related data * @param {string} chartId - chart id * @param {number} traceNum - trace index to remove * @param {Function} [dispatcher=flux.process] - only for special dispatching uses such as remote * @public * @function dispatchChartTraceRemove * @memberof firefly.action */ export function dispatchChartTraceRemove(chartId, traceNum, dispatcher= flux.process) { dispatcher({type: CHART_TRACE_REMOVE, payload: {chartId, traceNum}}); } /** * Update chart data. The parameter should have a partial object it wants to update. * The keys of the partial object should be in path-string format, ie. 'a.b.c'. * @summary Update chart data. * @param {Object} p - dispatch parameetrs * @param {string} p.chartId - chart id * @param {Object} p.changes - object with the path-string keys and values of the changed props * @param {Function} [p.dispatcher=flux.process] - only for special dispatching uses such as remote * @public * @function dispatchChartUpdate * @memberof firefly.action */ export function dispatchChartUpdate({chartId, changes, dispatcher=flux.process}) { dispatcher({type: CHART_UPDATE, payload: {chartId, changes}}); } /** * Highlight the given highlighted data point. Highlighted is an index into the current data array. * @param {object} p parameter object * @param {string} p.chartId required. * @param {number} p.highlighted index of the current data array * @param {number} [p.traceNum] - highlighted trace number * @param {number} [p.traceName] - highlighted trace name * @param {boolean} [p.chartTrigger] - action is triggered by chart * @param {function} [p.dispatcher] */ export function dispatchChartHighlighted({chartId, highlighted, traceNum, traceName, chartTrigger, dispatcher=flux.process}) { dispatcher({type: CHART_HIGHLIGHT, payload: {chartId, highlighted, traceNum, traceName, chartTrigger}}); } /** * Perform filter on the current selection. This function applies only to data bound to table. * @param {object} p parameter object * @param {string} p.chartId required. * @param {number} [p.highlighted] highlight the data point if given. * @param {function} [p.dispatcher] */ export function dispatchChartFilterSelection({chartId, highlighted, dispatcher=flux.process}) { dispatcher({type: CHART_FILTER_SELECTION, payload: {chartId, highlighted}}); } /** * Perform select(checked) on the current selection. This function applies only to data bound to table. * @param {object} p parameter object * @param {string} p.chartId required. * @param {number[]} p.selIndexes required. An array of indexes to select. * @param {boolean} p.chartTrigger - action is triggered by chart * @param {function} [p.dispatcher] */ export function dispatchChartSelect({chartId, selIndexes, chartTrigger, dispatcher=flux.process}) { dispatcher({type: CHART_SELECT, payload: {chartId, selIndexes, chartTrigger}}); } /** * Perform select(checked) on the current selection. This function applies only to data bound to table. * @param {object} p parameter object * @param {string} p.chartId required. * @param {number} p.activeTrace required. An array of indexes to select. * @param {function} [p.dispatcher] */ export function dispatchSetActiveTrace({chartId, activeTrace, dispatcher=flux.process}) { dispatcher({type: CHART_SET_ACTIVE_TRACE, payload: {chartId, activeTrace}}); } /** * Put a chart into an expanded mode. * @param {string} chartId - chart id * @param {Function} [dispatcher=flux.process] - only for special dispatching uses such as remote */ export function dispatchChartExpanded(chartId, dispatcher=flux.process) { dispatcher( {type: CHART_UI_EXPANDED, payload: {chartId}}); } /* * Dispatched when chart becomes visible (is rendered for the first time after being invisible) * When chart is mounted, its data need to be in sync with the related table * @param {string} chartId - chart id * @param {Function} [dispatcher=flux.process] - only for special dispatching uses such as remote */ export function dispatchChartMounted(chartId, dispatcher= flux.process) { dispatcher({type: CHART_MOUNTED, payload: {chartId}}); } /* * Dispatched when chart becomes invisible * When chart is unmounted, its data synced with the related table only when it becomes mounted again * @param {string} chartId - chart id * @param {Function} [dispatcher=flux.process] - only for special dispatching uses such as remote */ export function dispatchChartUnmounted(chartId, dispatcher= flux.process) { dispatcher({type: CHART_UNMOUNTED, payload: {chartId}}); } /*------------------------------- action creators -------------------------------------------*/ function chartAdd(action) { return (dispatch) => { const {chartId, chartType, deletable, renderTreeId} = action.payload; clearChartConn({chartId}); if (chartType === 'plot.ly') { // the action payload might need to be updated for firefly trace types const newPayload = handleFireflyTraceTypes(action.payload); // use application default if deletable is not defined for this chart if (isUndefined(deletable)) { newPayload.deletable = get(getAppOptions(), 'charts.defaultDeletable'); } const actionToDispatch = (newPayload === action.payload) ? action : Object.assign({}, action, {payload: newPayload}); dispatch(actionToDispatch); const {viewerId, data, fireflyData} = actionToDispatch.payload; if (viewerId) { // viewer will be added if it does not exist already dispatchAddViewerItems(viewerId, [chartId], 'plot2d', renderTreeId); } // lazy table connection // handle reset case - when a chart is already mounted const {mounted} = getChartData(chartId); if (mounted > 0) { handleTableSourceConnections({chartId, data, fireflyData}); } if (!cleanupWatcherStarted) { dispatchAddActionWatcher({id: 'chartCleanup', actions:[TablesCntlr.TABLE_REMOVE], callback: cleanupRelatedChartData}); cleanupWatcherStarted = true; } } else { // supporting deprecated API, which uses the specialized parameters const {chartId, chartType, params={}, ...rest} = action.payload; const {tbl_id} = params; const doChartAdd = (p) => { const chartData = chartType === 'scatter' ? makeXYPlotParams(p) : makeHistogramParams(p); if (chartData) { dispatchChartAdd({chartId, ...chartData, ...rest}); } }; // @callback {actionWatcherCallback} const onTblLoad = (action, cancelSelf, params) => { if (get(action.payload, 'tbl_id') === tbl_id) { doChartAdd(params); cancelSelf && cancelSelf(); } }; if (isFullyLoaded(tbl_id)) { doChartAdd(params); } else { // add watcher that will add chart on table load const actions = [TablesCntlr.TABLE_LOADED]; dispatchAddActionWatcher({id:`onTblLoad-${chartId}`, actions, callback: onTblLoad, params}); } } }; } function chartRemove(action) { return (dispatch) => { const {chartId} = action.payload; clearChartConn({chartId}); const viewerId = get(getChartData(chartId), 'viewerId'); if (viewerId) { dispatchRemoveViewerItems(viewerId, [chartId]); if (getViewer(getMultiViewRoot(), viewerId).customData.activeItemId === chartId) { dispatchUpdateCustom(viewerId, {activeItemId: undefined}); } } dispatch({type: action.type, payload: Object.assign({},action.payload, {viewerId})}); }; } function chartTraceRemove(action) { return (dispatch) => { const {chartId, traceNum} = action.payload; const {tablesources=[]} = getChartData(chartId); // cancel and replace table watchers, affected by the trace removal tablesources.forEach((ts,i) => { if (i>=traceNum && ts && ts._cancel) { ts._cancel(); if (i !== traceNum) { const newIdx = i-1; ts._cancel = setupTableWatcher(chartId, ts, newIdx); } } }); dispatch(action); }; } function chartUpdate(action) { return (dispatch) => { const {chartId, changes} = action.payload; // remove any table's mappings from changes because it will be applied by the connectors. const changesWithoutTblMappings = omitBy(changes, (v) => isString(v) && v.match(TBL_SRC_PATTERN)); set(action, 'payload.changes', changesWithoutTblMappings); dispatch(action); const {data, fireflyData} = Object.entries(changes) .filter(([k,]) => (k.startsWith('data') || k.startsWith('fireflyData'))) .reduce( (p, [k,v]) => set(p, k, v), {}); // take all of the data changes and create an object from it. // lazy table connection const {mounted} = getChartData(chartId); if (mounted > 0) { handleTableSourceConnections({chartId, data, fireflyData}); } }; } function chartHighlight(action) { return (dispatch) => { const {chartId, highlighted=0, chartTrigger=false} = action.payload; // TODO: activeTrace is not implemented. switch to trace.. then highlight(?) const {data, fireflyData, tablesources, activeTrace:activeDataTrace=0, selected} = getChartData(chartId); const {traceNum=activeDataTrace, traceName} = action.payload; // highlighted trace can be selected or highlighted trace of the data trace // when skipping hover, clicking on chart point produces no event // disable chart highlight in this case if (get(data, `${traceNum}.hoverinfo`) === 'skip') { return; } const ttype = get(data, [traceNum, 'type'], 'scatter'); if (!isEmpty(tablesources) && ttype.includes('scatter')) { // activeTrace is different from activeDataTrace if a selected point highlighted, for example const {tbl_id} = tablesources[activeDataTrace] || {}; if (!tbl_id) return; // avoid updating chart twice // update only as a response to table highlight change if (!chartTrigger) { const traceAnnotations = get(fireflyData, `${traceNum}.annotations`); const hlTrace = newTraceFrom(data[traceNum], [highlighted], HIGHLIGHTED_PROPS, traceAnnotations); dispatchChartUpdate({chartId, changes: {highlighted: hlTrace}}); } let traceData = data[traceNum]; if (traceNum !== activeDataTrace) { if (traceName === SELECTED_PROPS.name) { // highlighting selected point traceData = selected; } else if (traceName === HIGHLIGHTED_PROPS.name) { // no need to highlight highlighted return; } } if (traceData) { const highlightedRowIdx = getRowIdx(traceData, highlighted); TablesCntlr.dispatchTableHighlight(tbl_id, highlightedRowIdx); } } }; } function chartSelect(action) { return (dispatch) => { const {chartId, selIndexes=[], chartTrigger=false} = action.payload; const {activeTrace=0, data, fireflyData, tablesources} = getChartData(chartId); // when skipping hover, selecting chart points does not work // disable chart select in this case if (get(data, `${activeTrace}.hoverinfo`) === 'skip') { return; } let selected = undefined; if (!isEmpty(tablesources)) { const {tbl_id} = tablesources[activeTrace] || {}; const {totalRows} = getTblById(tbl_id); const selectInfoCls = SelectInfo.newInstance({rowCount: totalRows}); selIndexes.forEach((idx) => { selectInfoCls.setRowSelect(getRowIdx(data[activeTrace], idx), true); }); TablesCntlr.dispatchTableSelect(tbl_id, selectInfoCls.data); } // avoid updating chart twice // don't update before table select if (!chartTrigger) { const hasSelected = !isEmpty(selIndexes); const traceAnnotations = get(fireflyData, `${activeTrace}.annotations`); selected = newTraceFrom(data[activeTrace], selIndexes, SELECTED_PROPS, traceAnnotations); dispatchChartUpdate({chartId, changes: {hasSelected, selected, selection: undefined}}); } }; } function chartFilterSelection(action) { return (dispatch) => { const {chartId} = action.payload; const {activeTrace=0, selection, tablesources} = getChartData(chartId); if (!isEmpty(tablesources)) { const {tbl_id, mappings} = tablesources[activeTrace]; const numericCols = getColumns(getTblById(tbl_id), COL_TYPE.NUMBER).map((c) => c.name); let {x,y} = mappings; let upperLimit = get(mappings, `fireflyData.${activeTrace}.yMax`); let lowerLimit = get(mappings, `fireflyData.${activeTrace}.yMin`); // use standard form without spaces for filter key // to make sure the key is replaced when setFilter is used [x,y,upperLimit,lowerLimit] = [x, y, upperLimit,lowerLimit].map((v) => v && formatColExpr({colOrExpr:v, colNames:numericCols})); if (upperLimit) { y = `ifnull(${y},${upperLimit})`; } else if (lowerLimit) { y = `ifnull(${y},${lowerLimit})`; } const [xMin, xMax] = get(selection, 'range.x', []); const [yMin, yMax] = get(selection, 'range.y', []); const {request} = getTblById(tbl_id); const filterInfoCls = FilterInfo.parse(request.filters); filterInfoCls.setFilter(x, '> ' + xMin); filterInfoCls.addFilter(x, '< ' + xMax); filterInfoCls.setFilter(y, '> ' + yMin); filterInfoCls.addFilter(y, '< ' + yMax); // filters are processed by db, column expressions need to use syntax db understands // filters can be set for any column type, numeric or non-numeric const allColumns = getColumns(getTblById(tbl_id)).map((c) => c.name); const formatKey = (k) => formatColExpr({colOrExpr:k, quoted:true, colNames:allColumns}); const newRequest = Object.assign({}, request, {filters: filterInfoCls.serialize(formatKey)}); TablesCntlr.dispatchTableFilter(newRequest); dispatchChartUpdate({chartId, changes:{selection: undefined}}); } }; } function setActiveTrace(action) { return (dispatch) => { const {chartId, activeTrace} = action.payload; const {data, fireflyData, tablesources, curveNumberMap} = getChartData(chartId); const changes = getActiveTraceChanges({activeTrace, data, fireflyData, tablesources, curveNumberMap}); dispatchChartUpdate({chartId, changes}); }; } function getActiveTraceChanges({activeTrace, data, fireflyData, tablesources, curveNumberMap}) { const tbl_id = get(tablesources, [activeTrace, 'tbl_id']); let selected = undefined; let highlighted = undefined; let curveMap = undefined; if (tbl_id) { const {selectInfo, highlightedRow} = getTblById(tbl_id) || {}; const traceAnnotations = get(fireflyData, `${activeTrace}.annotations`); if (selectInfo) { const selectInfoCls = SelectInfo.newInstance(selectInfo); const selIndexes = Array.from(selectInfoCls.getSelected()).map((e)=>getPointIdx(data[activeTrace], e)); if (selIndexes.length > 0) { selected = newTraceFrom(data[activeTrace], selIndexes, SELECTED_PROPS, traceAnnotations); } } highlighted = newTraceFrom(data[activeTrace], [getPointIdx(data[activeTrace], highlightedRow)], HIGHLIGHTED_PROPS, traceAnnotations); if (curveNumberMap) { curveMap = range(curveNumberMap.length).filter((idx) => (idx !== activeTrace)); curveMap.push(activeTrace); } } return {activeTrace, selected, highlighted, selection: undefined, curveNumberMap: curveMap}; } function isFireflyType(type) { return !type || FIREFLY_TRACE_TYPES.includes(type); } /** * Move firefly attributes from data and layout objects to fireflyData and fireflyLayout * @param payload - original action payload * @return updated action payload */ function handleFireflyTraceTypes(payload) { if (payload['fireflyData']) return payload; const {data=[], layout={}} = payload; let newPayload = payload; if (data.find((d) => isFireflyType(d.type))) { const fireflyData = []; const plotlyData = []; data.forEach((d) => { if (isFireflyType(d.type)) { const fd = get(d, 'firefly', {}); fd.dataType = d.type; fireflyData.push(fd); plotlyData.push(omit(d, ['firefly'])); } else { fireflyData.push(undefined); plotlyData.push(d); } }); newPayload = Object.assign({}, newPayload, {data: plotlyData, fireflyData}); } if (layout.firefly || isArray(layout.annotations)) { const fireflyLayout = layout.firefly; const plotlyLayout = omit(layout, 'firefly'); if (isArray(layout.annotations)) { // save annotations array into fireflyLayout if (!fireflyLayout.annotations) { fireflyLayout.annotations = []; } fireflyLayout.annotations.push(plotlyLayout.annotations); } newPayload = Object.assign({}, newPayload, {layout: plotlyLayout, fireflyLayout}); } return newPayload; } /*-----------------------------------------------------------------------------------------*/ /* Possible structure of store: /ui expanded: Object - the information about expanded chart { chartId: string } /data - chart data, object with the keys that are chart id chart data */ export function reducer(state={ui:{}, data:{}}, action={}) { if (!action.type.startsWith(TablesCntlr.DATA_PREFIX) && !action.type.startsWith(CHART_SPACE_PATH)){ if (action.type === REINIT_APP) { return {ui:{}, data:{}}; } else { return state; } } const nstate = {...state}; //nstate.xyplot = reduceXYPlot(state['xyplot'], action); //nstate.histogram = reduceHistogram(state['histogram'], action); nstate.data = reduceData(state['data'], action); // generic for all chart types nstate.ui = reduceUI(state['ui'], action); if (shallowequal(state, nstate)) { return state; } else { return nstate; } } function changeToScatterGL(chartData) { get(chartData, 'data', []).forEach((d) => d.type === 'scatter' && (d.type = 'scattergl')); // use scattergl instead of scatter ['selected', 'highlighted'].map((k) => get(chartData, k, {})).forEach((d) => d.type === 'scatter' && (d.type = 'scattergl')); } /** * @param state - ui part of chart state * @param action - action * @returns {*} - updated ui part of the state */ function reduceData(state={}, action={}) { //if (chartActions.indexOf(action.type) < 0) { return state; } // useful when debugging switch (action.type) { case (CHART_ADD) : { const {chartId, chartType, mounted, ...rest} = action.payload; // if a chart is replaced (added with the same id) mounted should not change const nMounted = isUndefined(mounted) ? get(state, [chartId, 'mounted']) : mounted; // save the original payload, so that the chart could be recreated rest['_original'] = cloneDeep(action.payload); applyDefaults(rest); useScatterGL && changeToScatterGL(rest); // the first trace is put as the last curve for plotly rendering const tData = get(rest, ['data', 'length']); if (tData) { Object.assign(rest, {activeTrace: tData-1, curveNumberMap: range(tData)}); } state = updateSet(state, chartId, omitBy({ chartType, mounted: nMounted, ...rest }, isUndefined)); isDebug() && console.log(`CHART_ADD ${chartId} #mounted ${nMounted}`); return state; } case (CHART_UPDATE) : { const {chartId, changes} = action.payload; let chartData = getChartData(chartId); chartData = updateObject(chartData, changes); useScatterGL && changeToScatterGL(chartData); return updateSet(state, chartId, chartData); } case (CHART_TRACE_REMOVE) : { const {chartId, traceNum} = action.payload; const {changes, moreChanges} = removeTrace({chartId, traceNum}); let chartData = getChartData(chartId); if (!isEmpty(changes)) { // changes to array fields: data, fireflyData, etc chartData = updateObject(chartData, changes); if (!isEmpty(moreChanges)) { // changes to the specific trace attributes chartData = updateObject(chartData, moreChanges); } } return updateSet(state, chartId, chartData); } case (CHART_REMOVE) : { const {chartId} = action.payload; isDebug() && console.log('CHART_REMOVE '+chartId); return omit(state, chartId); } case (CHART_MOUNTED) : { const {chartId} = action.payload; if (has(state, chartId)) { const n = get(state, [chartId,'mounted'], 0); state = updateSet(state, [chartId,'mounted'], Number(n) + 1); isDebug() && console.log(`CHART_MOUNTED ${chartId} #mounted ${state[chartId].mounted}`); } return state; } case (CHART_UNMOUNTED) : { const {chartId} = action.payload; if (has(state, chartId)) { const n = get(state, [chartId,'mounted'], 0); if (n > 0) { state = updateSet(state, [chartId, 'mounted'], Number(n) - 1); } else { logError(`CHART_UNMOUNT on unmounted chartId ${chartId}`); } isDebug() && console.log(`CHART_UNMOUNTED ${chartId} #mounted ${state[chartId].mounted}`); } return state; } default: return state; } } export function getAnnotations(chartId) { const chartData = getChartData(chartId); let annotations = get(chartData, 'fireflyLayout.annotations', EMPTY_ARRAY); get(chartData, 'fireflyData', []).forEach((d) => { const traceAnnotations = flattenAnnotations(d.annotations); if (traceAnnotations.length > 0) { annotations = annotations.concat(traceAnnotations); } }); return annotations; } /** * Return trace symbol * In some cases we use distinct symbols to mark the specific points of the trace (ex. upper limits) * In these cases data.[traceNum].marker.symbol will be an array and fireflyData.[traceNum].marker.symbol * will contain the trace symbol * @param data * @param fireflyData * @param traceNum * @returns {*} */ export function getTraceSymbol(data, fireflyData, traceNum) { let symbol = get(data, `${traceNum}.marker.symbol`, 'circle'); if (isArray(symbol)) { symbol = get(fireflyData, `${traceNum}.marker.symbol`, 'circle'); } return symbol; } /** * @param state - ui part of chart state * @param action - action * @returns {*} - updated ui part of the state */ function reduceUI(state={}, action={}) { switch (action.type) { case (CHART_UI_EXPANDED) : const {chartId} = action.payload; return updateSet(state, 'expanded', chartId); case (CHART_REMOVE) : if (get(action.payload, 'chartId') === get(getExpandedChartProps(), 'chartId')) { return omit(state, 'expanded'); } return state; default: return state; } } /** * @callback actionWatcherCallback * @param action */ function cleanupRelatedChartData(action) { const tbl_id = get(action.payload, 'tbl_id'); if (!tbl_id) return; const charts = get(flux.getState(), [CHART_SPACE_PATH, 'data']); if (!charts || isEmpty(charts)) { return; } const getMatchingTSIdx = (chartId) => { return get(getChartData(chartId), 'tablesources', []).findIndex((e) => get(e, 'tbl_id') === tbl_id); }; Object.keys(charts).forEach((chartId) => { let traceNum = getMatchingTSIdx(chartId); while ( traceNum >= 0) { const {data, tablesources} = getChartData(chartId); // remove trace or remove chart if the last trace tablesources[traceNum]._cancel && tablesources[traceNum]._cancel(); if (data.length === 1) { dispatchChartRemove(chartId); } else { dispatchChartTraceRemove(chartId, traceNum); } traceNum = getMatchingTSIdx(chartId); } }); } export function hasUpperLimits(chartId, traceNum) { const yMax = get(getChartData(chartId), `fireflyData.${traceNum}.yMax`); return !isUndefined(yMax); } export function hasLowerLimits(chartId, traceNum) { const yMin = get(getChartData(chartId), `fireflyData.${traceNum}.yMin`); return !isUndefined(yMin); } export function dataLoadedUpdate(changes) { // when the chart data finished loading, fireflyData.traceNum.isLoading is switched to false return Object.keys(changes).find((k)=>(k.match(/isLoading$/) && !changes[k])); } /** * Reset chart to the original * @param chartId */ export function resetChart(chartId) { const {_original} = getChartData(chartId); _original && dispatchChartAdd(_original); } function removeTrace({chartId, traceNum}) { const {activeTrace, data, fireflyData, layout, tablesources, curveNumberMap} = getChartData(chartId); const changes = {}; const moreChanges = {}; [[data, 'data'], [fireflyData, 'fireflyData'], [tablesources, 'tablesources']].forEach(([arr,name]) => { if (arr && traceNum < arr.length) { changes[name] = arr.filter((e,i) => i !== traceNum); } }); // handle colorbars if (hasFireflyColorbar(chartId, traceNum)) { Object.assign(moreChanges, adjustColorbars({data: changes['data'], fireflyData: changes['fireflyData'], layout})); } if (curveNumberMap && traceNum < curveNumberMap.length) { // new curve map has the same order of traces as the old curve map const newCurveMap = curveNumberMap.filter((e) => (e !== traceNum)).map((e) => (e > traceNum ? e-1 : e)); changes['curveNumberMap'] = newCurveMap; if (newCurveMap.length > 0) { const newActiveTrace = newCurveMap[newCurveMap.length-1]; if (newActiveTrace !== activeTrace) { changes['activeTrace'] = newActiveTrace; } if (traceNum === activeTrace) { Object.assign(changes, getActiveTraceChanges({activeTrace: newActiveTrace, data: changes['data'], fireflyData: changes['fireflyData'], tablesources: changes['tablesources'], curveNumberMap: newCurveMap}) ); } } } return {changes, moreChanges}; } export function getChartData(chartId, defaultChartData={}) { return get(flux.getState(), [CHART_SPACE_PATH, 'data', chartId], defaultChartData); } /** * Get error object associated with the given chart data element * @param chartId * @returns {Array<{message:string, reason:object}>} an array of error objects */ export function getErrors(chartId) { const errors = []; const chartData = get(flux.getState(), [CHART_SPACE_PATH, 'data', chartId], {}); get(chartData, 'fireflyData', []).forEach((d) => { const error = get(d, 'error'); error && errors.push(error); }); return errors; } export function dispatchError(chartId, traceNum, reason) { const {data=[]} = getChartData(chartId); let forTrace = ''; if (data.length > 1) { // only mention trace, when there are multiple traces const name = get(data, `${traceNum}.name`, `trace ${traceNum}`); forTrace = ` for ${name}`; } let message = `Cannot display requested data${forTrace}`; logError(`${message}: ${reason}`); let reasonStr = `${reason}`.toLowerCase(); if (reasonStr.match(/not supported/)) { reasonStr = 'Unsupported feature requested. Please choose valid options.'; } else if (reasonStr.match(/data exception/) || reasonStr.match(/column not found/)) { // error from db reasonStr = reasonStr.replace('error: ', ''); } else if (reasonStr.match(/invalid column/)) { reasonStr = 'Non-existent column or invalid expression. Please choose valid X and Y.'; } else if (reasonStr.match(/rows exceed/)) { message = 'Please filter the table or use different chart type.'; reasonStr = reason; // } else if (reasonStr.match(/same column/)) { // message = 'The columns requested are identical or one of them is not numerical.'; // reasonStr = reason; } else if (reasonStr.match(/null/)){ message = `No data available: ${name} data`; reasonStr = ''; } else { reasonStr = ''; } const changes = {}; changes[`fireflyData.${traceNum}.error`] = {message, reason: reasonStr}; changes[`fireflyData.${traceNum}.isLoading`] = false; dispatchChartUpdate({chartId, changes}); } export function getExpandedChartProps() { const chartId = get(flux.getState(), [CHART_SPACE_PATH, 'ui', 'expanded']); return {chartId}; } export function getChartIdsInGroup(groupId) { const chartIds = []; const state = get(flux.getState(), [CHART_SPACE_PATH, 'data']); Object.keys(state).forEach((cid) => { if (state[cid].groupId === groupId) { chartIds.push(cid); } }); return chartIds; } export function removeChartsInGroup(groupId) { const chartData = get(flux.getState(), [CHART_SPACE_PATH, 'data']); Object.values(chartData) .filter( (v) => !groupId || v.groupId === groupId) .forEach( (v) => dispatchChartRemove(v.chartId)); }