diff --git a/src/firefly/js/charts/ChartUtil.js b/src/firefly/js/charts/ChartUtil.js
index b7996bc403..f4dbf444b3 100644
--- a/src/firefly/js/charts/ChartUtil.js
+++ b/src/firefly/js/charts/ChartUtil.js
@@ -16,18 +16,14 @@ import shallowequal from 'shallowequal';
import {getAppOptions} from '../core/AppDataCntlr.js';
import {
- COL_TYPE, getColumnType, getMetaEntry, getTblById, isColumnType, isFullyLoaded, isTableLoaded,
- stripColumnNameQuotes, SYS_COLUMNS, watchTableChanges
+ COL_TYPE, ensureEnumVals, getColumn, getColumnIdx, getColumns, getColumnType, getMetaEntry, getTblById, isColumnType,
+ isFullyLoaded, isTableLoaded, splitVals, stripColumnNameQuotes, SYS_COLUMNS, watchTableChanges
} from '../tables/TableUtil.js';
-import {TABLE_HIGHLIGHT, TABLE_LOADED, TABLE_SELECT, TABLE_SORT} from '../tables/TablesCntlr.js';
+import {TABLE_FILTER, TABLE_HIGHLIGHT, TABLE_LOADED, TABLE_SELECT, TABLE_SORT} from '../tables/TablesCntlr.js';
import {getSpectrumDM} from '../voAnalyzer/SpectrumDM.js';
import {findTableCenterColumns} from '../voAnalyzer/TableAnalysis.js';
import {dispatchLoadTblStats, getColValStats} from './TableStatsCntlr.js';
-import {
- dispatchChartHighlighted,
- dispatchChartSelect,
- dispatchChartUpdate,
- dispatchSetActiveTrace,
+import {dispatchChartHighlighted, dispatchChartSelect, dispatchChartUpdate, dispatchSetActiveTrace,
getChartData
} from './ChartsCntlr.js';
import {Expression} from '../util/expr/Expression.js';
@@ -37,7 +33,8 @@ 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 {getTraceTSEntries as spectrumTSGetter, spectrumPlot, spectrumType} from './dataTypes/FireflySpectrum.js';
+import {getTraceTSEntries as spectrumTSGetter, spectrumPlot, spectrumType
+} from './dataTypes/FireflySpectrum.js';
import {toRGBA as colorToRGBA} from '../util/Color.js';
import {MetaConst} from '../data/MetaConst';
import {ALL_COLORSCALE_NAMES, colorscaleNameToVal} from './Colorscale.js';
@@ -69,6 +66,48 @@ const FSIZE = 12;
export const TBL_SRC_PATTERN = /^tables::(.+)/;
+/**
+ * Extract table mappings from flattened chart data.
+ * @param {Object} flattenData flattened trace data
+ * @returns {Object} mappings keyed by trace data paths
+ */
+export function getTableSourceMappings(flattenData={}) {
+ return Object.entries(flattenData)
+ .filter(([, value]) => typeof value === 'string' && value.startsWith('tables::'))
+ .reduce((mappings, [key, value]) => {
+ const [, colExp] = value.match(TBL_SRC_PATTERN) || [];
+ if (colExp) mappings[key] = colExp;
+ return mappings;
+ }, {});
+}
+
+export function getFilterResetAutorange(axisLayout) {
+ const {autorange, range=[]} = axisLayout || {};
+ return autorange === 'reversed' || range[1] < range[0] ? 'reversed' : true;
+}
+
+export function getOriginalRowIndexes(tableModel) {
+ const origIdx = getColumnIdx(tableModel, 'ORIG_IDX');
+ return origIdx >= 0 ? tableModel?.tableData?.data?.map((row) => row[origIdx]) : undefined;
+}
+
+/**
+ * Check whether an async result belongs to the source currently installed
+ * for a chart trace. Cancelled requests may still finish, so stale results
+ * must be ignored after a source is removed or replaced.
+ *
+ * @param {string} chartId chart identifier
+ * @param {number} traceNum trace index
+ * @param {Object} tablesource source captured by the request
+ * @returns {boolean} true when the source is still current
+ */
+export function isCurrentTableSource(chartId, traceNum, tablesource) {
+ const chartData = getChartData(chartId);
+ const currentSource = chartData?.tablesources?.[traceNum];
+ return Boolean(chartData?.mounted && currentSource?._sourceId && tablesource?._sourceId &&
+ currentSource._sourceId === tablesource._sourceId);
+}
+
/**
* Does the application only support single trace
* @returns {*} true, if all charts are single trace
@@ -287,7 +326,8 @@ export function combineAllTraceFrom(chartId, selIndexes, newTraceProps) {
const combine = (flatTarget, source) => {
Object.entries(flatTarget).forEach( ([key, value]) => {
if (Array.isArray(value)) {
- value.push(...get(source, key));
+ const sourceValue = get(source, key);
+ if (Array.isArray(sourceValue)) value.push(...sourceValue);
}
});
return flatTarget;
@@ -322,6 +362,220 @@ export function combineAllTraceFrom(chartId, selIndexes, newTraceProps) {
}
}
+//Chart "Group By" logic helpers below
+export function getGroupByColumn(chartId) {
+ const {fireflyData=[]} = getChartData(chartId) || {};
+ return fireflyData.find((fd) => fd?.groupBy?.column)?.groupBy.column || '';
+}
+
+export function isGroupedChart(chartId) {
+ return Boolean(getGroupByColumn(chartId));
+}
+
+export function isGroupByAllowed(chartId, activeTrace) {
+ const {data=[]} = getChartData(chartId) || {};
+ if (!isUndefined(activeTrace) && activeTrace >= data.length) return false;
+ return Boolean(chartId) && (data.length <= 1 || isGroupedChart(chartId));
+}
+
+export function getGroupableColumns(tbl_id) {
+ const tableModel = getTblById(tbl_id);
+ if (!tableModel?.tableData?.columns) return [];
+
+ const tableCopy = simpleCloneDeep(tableModel);
+ ensureEnumVals(tableCopy); //cloning table model to get list of enum-like columns in tableCopy
+
+ return getColumns(tableCopy)
+ .filter(({name, enumVals, visibility}) => enumVals && !SYS_COLUMNS.includes(name) && visibility !== 'hidden')
+ .map((c) => ({...c}));
+}
+
+export function makeScatterGroupByChanges({chartId, tbl_id, activeTrace=0, groupByColumn='', changes={}}) {
+ const chartData = simpleCloneDeep(getChartData(chartId, {}));
+ Object.entries(changes).forEach(([key, value]) => set(chartData, key, value));
+
+ const data = chartData.data || [];
+ const fireflyData = chartData.fireflyData || [];
+ const baseData = simpleCloneDeep(data[activeTrace] || {});
+ const baseFirefly = simpleCloneDeep(fireflyData[activeTrace] || {});
+ const activeTablesource = chartData.tablesources?.[activeTrace] || {};
+ const baseState = baseFirefly?.groupBy?.baseState
+ ? simpleCloneDeep(baseFirefly.groupBy.baseState)
+ : makeGroupByBaseState(baseData, baseFirefly, activeTablesource, activeTrace, tbl_id);
+ restoreTableSourceMappings({
+ trace: baseData,
+ fireflyTrace: baseFirefly,
+ tablesource: activeTablesource,
+ traceNum: activeTrace,
+ tbl_id
+ });
+ restoreGroupByBaseStyle(baseData, baseFirefly);
+
+ const commonChanges = {
+ showOptions: false,
+ highlighted: undefined,
+ selected: undefined,
+ selection: undefined,
+ hasSelected: false
+ };
+
+ if (!groupByColumn) {
+ const ungroupedData = simpleCloneDeep(baseState.data);
+ const ungroupedFirefly = simpleCloneDeep(baseState.fireflyData);
+ clearGroupBy(ungroupedData, ungroupedFirefly);
+ restoreTableSourceMappings({
+ trace: ungroupedData,
+ fireflyTrace: ungroupedFirefly,
+ tablesource: {tbl_id: baseState.tbl_id, mappings: baseState.mappings},
+ traceNum: baseState.traceNum,
+ tbl_id
+ });
+ return {
+ ...commonChanges,
+ data: [ungroupedData],
+ fireflyData: [ungroupedFirefly],
+ activeTrace: 0,
+ curveNumberMap: makeCurveNumberMap(1, 0),
+ 'layout.showlegend': false,
+ 'layout.legend.title.text': undefined
+ };
+ }
+
+ const groupCol = getGroupableColumns(tbl_id).find((c) => c.name === groupByColumn) || getColumn(getTblById(tbl_id), groupByColumn);
+ const groupValues = splitVals(groupCol?.enumVals)
+ .map((v) => String(v ?? '').trim().replace(/^'(.*)'$/, '$1'))
+ .filter((v) => v !== '');
+ if (!groupCol || groupValues.length === 0) {
+ const ungroupedData = simpleCloneDeep(baseState.data);
+ const ungroupedFirefly = simpleCloneDeep(baseState.fireflyData);
+ clearGroupBy(ungroupedData, ungroupedFirefly);
+ restoreTableSourceMappings({
+ trace: ungroupedData,
+ fireflyTrace: ungroupedFirefly,
+ tablesource: {tbl_id: baseState.tbl_id, mappings: baseState.mappings},
+ traceNum: baseState.traceNum,
+ tbl_id
+ });
+ return {
+ ...commonChanges,
+ data: [ungroupedData],
+ fireflyData: [ungroupedFirefly],
+ activeTrace: 0,
+ curveNumberMap: makeCurveNumberMap(1, 0),
+ 'layout.showlegend': false,
+ 'layout.legend.title.text': undefined
+ };
+ }
+
+ const groupData = [];
+ const groupFireflyData = [];
+ groupValues.forEach((value, idx) => {
+ const trace = simpleCloneDeep(baseData);
+ const fd = simpleCloneDeep(baseFirefly);
+ const color = toRGBA(TRACE_COLORS[idx % TRACE_COLORS.length]);
+
+ set(trace, 'name', value);
+ set(trace, 'showlegend', true);
+ set(trace, 'marker.color', color);
+ set(trace, 'line.color', color);
+ set(trace, 'error_x.color', color);
+ set(trace, 'error_y.color', color);
+ set(trace, 'legendgroup', uniqueId('grp'));
+
+ fd.groupBy = {
+ column: groupCol.name,
+ value,
+ baseTraceStyle: getGroupByBaseStyle(baseData),
+ baseState: simpleCloneDeep(baseState)
+ };
+ fd.filters = makeGroupFilter(groupCol.name, value);
+
+ groupData.push(trace);
+ groupFireflyData.push(fd);
+ });
+
+ const newActiveTrace = Math.min(activeTrace, groupData.length - 1);
+ return {
+ ...commonChanges,
+ data: groupData,
+ fireflyData: groupFireflyData,
+ activeTrace: newActiveTrace,
+ curveNumberMap: makeCurveNumberMap(groupData.length, newActiveTrace),
+ 'layout.showlegend': true,
+ 'layout.legend.title.text': groupCol.label || groupCol.name
+ };
+}
+
+function makeGroupByBaseState(trace, fireflyTrace, tablesource, traceNum, tbl_id) {
+ const data = simpleCloneDeep(trace);
+ const fd = simpleCloneDeep(fireflyTrace);
+
+ // Older automatically-grouped spectra do not yet carry an ungrouped snapshot.
+ if (fd?.groupBy) {
+ clearGroupBy(data, fd);
+ if (data.firefly) delete data.firefly.rowIdx;
+ }
+ return {
+ data,
+ fireflyData: fd,
+ tbl_id: tablesource?.tbl_id || data.tbl_id || fd.tbl_id || tbl_id,
+ mappings: simpleCloneDeep(tablesource?.mappings || {}),
+ traceNum
+ };
+}
+
+function makeCurveNumberMap(traceCount, activeTrace) {
+ return [...range(traceCount).filter((idx) => idx !== activeTrace), activeTrace];
+}
+
+function makeGroupFilter(columnName, value) {
+ const escapedColumn = columnName.replace(/"/g, '""');
+ const escapedValue = String(value).replace(/'/g, "''");
+ return `"${escapedColumn}" = '${escapedValue}'`;
+}
+
+function clearGroupBy(trace, fireflyTrace) {
+ restoreGroupByBaseStyle(trace, fireflyTrace);
+ if (fireflyTrace?.groupBy) {
+ delete trace.name;
+ delete trace.legendgroup;
+ delete trace.showlegend;
+ }
+ if (fireflyTrace) {
+ delete fireflyTrace.groupBy;
+ delete fireflyTrace.filters;
+ }
+}
+
+function getGroupByBaseStyle(trace) {
+ return ['marker.color', 'line.color', 'error_x.color', 'error_y.color']
+ .reduce((style, path) => {
+ if (has(trace, path)) set(style, path, get(trace, path));
+ return style;
+ }, {});
+}
+
+function restoreGroupByBaseStyle(trace, fireflyTrace) {
+ const baseTraceStyle = fireflyTrace?.groupBy?.baseTraceStyle;
+ if (!baseTraceStyle) return;
+ Object.entries(flattenObject(baseTraceStyle)).forEach(([path, value]) => set(trace, path, value));
+}
+
+function restoreTableSourceMappings({trace={}, fireflyTrace={}, tablesource={}, traceNum=0, tbl_id}) {
+ if (tablesource.tbl_id && !trace.tbl_id) trace.tbl_id = tablesource.tbl_id;
+ if (!trace.tbl_id && tbl_id) trace.tbl_id = tbl_id;
+
+ Object.entries(tablesource.mappings || {}).forEach(([key, value]) => {
+ if (value === undefined || value === '') return;
+ const fireflyPrefix = `fireflyData.${traceNum}.`;
+ if (key.startsWith(fireflyPrefix)) {
+ set(fireflyTrace, key.slice(fireflyPrefix.length), `tables::${value}`);
+ } else {
+ set(trace, key, `tables::${value}`);
+ }
+ });
+}
+
export function newTraceFrom(data, selIndexes, newTraceProps, traceAnnotations) {
const sdata = simpleCloneDeep(pick(data, ['x', 'y', 'z', 'legendgroup', 'error_x', 'error_y', 'text', 'hovertext', 'marker', 'hoverinfo', 'firefly' ]));
@@ -512,15 +766,35 @@ export function handleBigInt(v) {
* @param {string} p.chartId
* @param {object[]} p.data
* @param {object[]} p.fireflyData
+ * @param {boolean} [p.replaceTableSources=false] replace and reload the complete table-source set
+ * @param {boolean} [p.syncExistingSources=false] reconnect and refresh retained table sources
*/
-export function handleTableSourceConnections({chartId, data, fireflyData}) {
+export function handleTableSourceConnections({chartId, data, fireflyData, replaceTableSources=false, syncExistingSources=false}) {
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;
+ // Some chart updates only modify Plotly trace/layout state, not the table source
+ // Preserve existing table watchers unless makeTableSources found a real source update
+ const hasTableSourceUpdates = Array.isArray(tablesources) && tablesources.some((ts) =>
+ ts?.tbl_id && (!isEmpty(ts.mappings) || typeof ts.fetchData === 'function'));
+ if (!hasTableSourceUpdates) {
+ oldTablesources.forEach((traceTS, idx) => {
+ if (!traceTS?.tbl_id) return;
+ if (!traceTS._sourceId) traceTS._sourceId = uniqueId('chart-source-');
+ if (!traceTS._cancel) traceTS._cancel = setupTableWatcher(chartId, traceTS, idx);
+ if (syncExistingSources) updateChartData(chartId, idx, traceTS);
+ });
+ return;
+ }
+
+ const numTraces = replaceTableSources
+ ? tablesources.length
+ : Math.max(tablesources.length, oldTablesources.length);
+
+ if (replaceTableSources) {
+ oldTablesources.slice(numTraces).forEach((traceTS) => traceTS?._cancel?.());
+ }
- 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] || {};
@@ -538,7 +812,7 @@ export function handleTableSourceConnections({chartId, data, fireflyData}) {
}
}
- if (!tablesourcesEqual(traceTS, oldTraceTS)) {
+ if (replaceTableSources || !tablesourcesEqual(traceTS, oldTraceTS)) {
if (oldTraceTS && oldTraceTS._cancel) {
oldTraceTS._cancel(); // cancel the previous watcher if exists
oldTraceTS._cancel = undefined;
@@ -553,9 +827,13 @@ export function handleTableSourceConnections({chartId, data, fireflyData}) {
if (!isEmpty(traceTS)) {
//creates a new one.. and save the cancel handle
if (doUpdate) {
+ //assign a unique id to this table source so queued watcher events from a
+ //replaced source can be ignored before they update the chart (this led to filtering/pinning related bugs after introducing chart grouping)
+ traceTS._sourceId = uniqueId('chart-source-');
// fetch data syncs highlighted and selected with the table
updateChartData(chartId, idx, traceTS);
} else {
+ if (!traceTS._sourceId) traceTS._sourceId = uniqueId('chart-source-');
if (idx === activeTrace && isFullyLoaded(traceTS.tbl_id)) {
// update highlighted and selected
const tableModel = getTblById(traceTS.tbl_id);
@@ -617,6 +895,18 @@ function updateChartData(chartId, traceNum, tablesource, action={}) {
// make sure the chart is not yet removed
if (isEmpty(chartData) || !chartData?.mounted) { return; }
+ // Ignore queued watcher events from an older table-source generation
+ // Table filters are global, so handle them with the current source,
+ // otherwise a stale source may point to an old grouped trace or old mappings
+ const currentSource = chartData.tablesources?.[traceNum];
+ const sourceIsCurrent = isCurrentTableSource(chartId, traceNum, tablesource);
+ const sourceWasReplaced = action.type && !sourceIsCurrent;
+ const isFilterEvent = action.type === TABLE_LOADED && action.payload.invokedBy === TABLE_FILTER;
+ if (sourceWasReplaced) {
+ if (!isFilterEvent || !currentSource) return;
+ tablesource = currentSource;
+ }
+
// Only Scatter Plot does update on a table sort event.
if (action.type === TABLE_LOADED && action.payload.invokedBy === TABLE_SORT) {
const traceType = get(chartData, ['data', traceNum, 'type'], 'scatter');
@@ -627,7 +917,7 @@ function updateChartData(chartId, traceNum, tablesource, action={}) {
if (action.type === TABLE_HIGHLIGHT) {
// ignore if traceNum is not active
const {activeTrace=0} = getChartData(chartId);
- if (traceNum !== activeTrace && !isSpectralOrder(chartId)) return;
+ if (traceNum !== activeTrace && !isGroupedChart(chartId)) return;
const {highlightedRow} = action.payload;
updateHighlighted(chartId, traceNum, highlightedRow);
} else if (action.type === TABLE_SELECT) {
@@ -643,6 +933,14 @@ function updateChartData(chartId, traceNum, tablesource, action={}) {
dispatchLoadTblStats(tableModel.request);
const changes = getDataChangesForMappings({mappings, traceNum});
+ // Filtering reloads table-backed traces. Clear fixed ranges so the chart
+ // rescales to filtered rows, but keep reversed-axis autorange intact
+ if (isFilterEvent) {
+ changes['layout.xaxis.autorange'] = getFilterResetAutorange(chartData.layout?.xaxis);
+ changes['layout.xaxis.range'] = undefined;
+ changes['layout.yaxis.autorange'] = getFilterResetAutorange(chartData.layout?.yaxis);
+ changes['layout.yaxis.range'] = undefined;
+ }
// save original table file path
const resultSetIDNow = get(tableModel, 'tableMeta.resultSetID');
@@ -655,16 +953,14 @@ function updateChartData(chartId, traceNum, tablesource, action={}) {
// fetch data for both Firefly recognized or unrecognized plotly chart types
if (tablesource.fetchData) {
- tablesource.fetchData(chartId, traceNum, tablesource);
+ const fetchSource = isFilterEvent
+ ? {...tablesource, resetAxes: true}
+ : tablesource;
+ tablesource.fetchData(chartId, traceNum, fetchSource);
}
}
}
-export function isSpectralOrder(chartId) {
- const {activeTrace=0, fireflyData} = getChartData(chartId) || {};
- return fireflyData?.[activeTrace]?.spectralOrder;
-}
-
export function isSpectrum(chartId) {
const {activeTrace=0, fireflyData} = getChartData(chartId) || {};
return fireflyData?.[activeTrace]?.dataType === spectrumType;
@@ -673,24 +969,11 @@ export function isSpectrum(chartId) {
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
+ const ds = data[traceNum] ? {mappings: getTraceTableSourceMappings(data[traceNum], fireflyData[traceNum], traceNum)} : {}; //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`);
@@ -704,12 +987,82 @@ function makeTableSources(chartId, data=[], fireflyData=[]) {
// 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}));
+ const traceOptions = get(fireflyData, [traceNum, 'options']);
+ Object.assign(ds, getTraceTSEntries({chartDataType,
+ traceTS: {...ds, options: traceOptions}, chartId, traceNum}));
}
return ds;
});
}
+function getTraceTableSourceMappings(dataTrace, fireflyTrace, traceNum) {
+ const fireflyDataFlatten = flattenObject(fireflyTrace || {}, `fireflyData.${traceNum}`);
+ // fireflyData mappings have full path
+ const flattenData = assign(flattenObject(dataTrace || {}), fireflyDataFlatten);
+ return getTableSourceMappings(flattenData);
+}
+
+function getGenericTableSourceMappings({data, fireflyData, tablesources, activeTrace, tbl_id}) {
+ const activeMappings = tablesources?.[activeTrace]?.mappings;
+ if (!isEmpty(activeMappings)) return activeMappings;
+
+ const sameTableSource = tablesources?.find((ts) =>
+ ts?.tbl_id && ts.tbl_id === tbl_id && !isEmpty(ts.mappings));
+ if (sameTableSource) return sameTableSource.mappings;
+
+ return getTraceTableSourceMappings(data?.[activeTrace], fireflyData?.[activeTrace], activeTrace);
+}
+
+function getGroupedTableSourceMappings({fireflyData, activeTrace, tbl_id}) {
+ const baseState = fireflyData?.[activeTrace]?.groupBy?.baseState;
+ if (!isEmpty(baseState?.mappings)) return baseState.mappings;
+
+ const sameTableBaseState = fireflyData?.find((fd) => {
+ const baseState = fd?.groupBy?.baseState;
+ return !isEmpty(baseState?.mappings) && (!tbl_id || baseState.tbl_id === tbl_id);
+ })?.groupBy?.baseState;
+ if (sameTableBaseState) return sameTableBaseState.mappings;
+
+ return {};
+}
+
+function getTypeSpecificTableSourceMappings({fireflyData, activeTrace}) {
+ const dataType = fireflyData?.[activeTrace]?.dataType;
+ const options = fireflyData?.[activeTrace]?.options;
+
+ if (dataType === 'fireflyHeatmap' && options) {
+ return {
+ x: options.xColOrExpr,
+ y: options.yColOrExpr
+ };
+ }
+
+ return {};
+}
+
+/**
+ * Resolve table mappings in priority order:
+ * 1. Current generic source mappings.
+ * 2. Grouped-chart base mappings.
+ * 3. Type-specific mappings, currently heatmap X/Y options.
+ *
+ * Grouped charts and heatmaps store mappings outside the normal source
+ * structure, so they require explicit fallbacks.
+ *
+ * @returns {Object} resolved table-column mappings
+ */
+function getResolvedTableSourceMappings({data, fireflyData, tablesources, activeTrace, tbl_id}) {
+ const genericMappings = getGenericTableSourceMappings({
+ data, fireflyData, tablesources, activeTrace, tbl_id
+ });
+ if (!isEmpty(genericMappings)) return genericMappings;
+
+ const groupedMappings = getGroupedTableSourceMappings({fireflyData, activeTrace, tbl_id});
+ if (!isEmpty(groupedMappings)) return groupedMappings;
+
+ return getTypeSpecificTableSourceMappings({fireflyData, activeTrace});
+}
+
function getTraceTSEntries({chartDataType, traceTS, chartId, traceNum}) {
if (chartDataType === 'fireflyHistogram') {
return histogramTSGetter({traceTS, chartId, traceNum});
@@ -1145,16 +1498,17 @@ function scatterOrHeatmap({tbl_id, xCol, yCol, xOptions}) {
export function getSelIndexes(data, selectInfoCls, traceIdx) {
return Array.from(selectInfoCls.getSelected()).map((rowIdx) => {
- let ptIdx = getPointIdx(data[traceIdx], rowIdx);
+ let rowTraceIdx = traceIdx;
+ let ptIdx = getPointIdx(data[rowTraceIdx], rowIdx);
if (ptIdx < 0) {
data.some((t, idx) => {
ptIdx = getPointIdx(t, rowIdx);
- traceIdx = idx;
+ rowTraceIdx = idx;
return ptIdx > -1;
});
}
- return [ptIdx, traceIdx];
- });
+ return [ptIdx, rowTraceIdx];
+ }).filter(([ptIdx]) => ptIdx >= 0);
}
@@ -1202,13 +1556,15 @@ function hasNoXY(type, tablesource) {
export function getChartProps(chartId, tbl_id, activeTrace) {
const {data, layout, fireflyLayout, tablesources, fireflyData, ...rest} = getChartData(chartId) || {};
activeTrace = activeTrace ?? rest.activeTrace ?? 0;
- const tablesource = get(tablesources, [activeTrace], tbl_id && {tbl_id});
- tbl_id = tbl_id || tablesource?.tbl_id;
-
- const mappings = tablesource?.mappings;
+ const activeTablesource = get(tablesources, [activeTrace]);
+ tbl_id = tbl_id || activeTablesource?.tbl_id || data?.[activeTrace]?.tbl_id || fireflyData?.[activeTrace]?.tbl_id;
+ const dataType = fireflyData?.[activeTrace]?.dataType;
+ const mappings = getResolvedTableSourceMappings({data, fireflyData, tablesources, activeTrace, tbl_id});
+ const tablesource = (activeTablesource || tbl_id)
+ ? {...(activeTablesource || {}), tbl_id, mappings}
+ : undefined;
const multiTrace = activeTrace > 0 || data?.length > 1;
const type = get(data, `${activeTrace}.type`, 'scatter');
- const dataType = fireflyData?.[activeTrace]?.dataType;
const noColor = !hasMarkerColor(type);
const noXY = hasNoXY(type, tablesource);
const isXNotNumeric = noXY ? undefined : isNonNumColumn(tbl_id, mappings?.x);
@@ -1226,9 +1582,9 @@ export function getChartProps(chartId, tbl_id, activeTrace) {
export function getTblIdFromChart(chartId, traceNum) {
- const {data, fireflyData, activeTrace} = getChartData(chartId) || {};
+ const {data, fireflyData, tablesources, activeTrace} = getChartData(chartId) || {};
traceNum = traceNum ?? activeTrace;
- return data?.[traceNum]?.tbl_id || fireflyData?.[traceNum]?.tbl_id;
+ return tablesources?.[traceNum]?.tbl_id || data?.[traceNum]?.tbl_id || fireflyData?.[traceNum]?.tbl_id;
}
export function hasTracesFromSameTable(chartId) {
@@ -1250,9 +1606,9 @@ function simpleCloneDeep(obj) {
const copy = {};
for (const key of Object.keys(obj)) {
- if (obj.hasOwnProperty(key)) {
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
copy[key] = simpleCloneDeep(obj[key]);
}
}
return copy;
-}
\ No newline at end of file
+}
diff --git a/src/firefly/js/charts/ChartsCntlr.js b/src/firefly/js/charts/ChartsCntlr.js
index 20f5f74f65..c1d780436c 100644
--- a/src/firefly/js/charts/ChartsCntlr.js
+++ b/src/firefly/js/charts/ChartsCntlr.js
@@ -2,27 +2,27 @@
* 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, unset} from 'lodash';
+import {cloneDeep, has, get, isArray, isEmpty, isString, isUndefined, omit, omitBy, set, range, unset, uniqueId} from 'lodash';
import shallowequal from 'shallowequal';
import {flux} from '../core/ReduxFlux.js';
import {updateSet, updateObject, toBoolean} from '../util/WebUtil.js';
import {Logger} from '../util/Logger.js';
-import {getTblById, getColumns, isFullyLoaded, COL_TYPE} from '../tables/TableUtil.js';
+import {getTblById, getColumn, getColumns, isFullyLoaded, COL_TYPE} from '../tables/TableUtil.js';
import {dispatchAddActionWatcher} from '../core/MasterSaga.js';
import * as TablesCntlr from '../tables/TablesCntlr.js';
import {dispatchAddViewerItems, dispatchUpdateCustom, dispatchRemoveViewerItems,
getMultiViewRoot, getViewer} from '../visualize/MultiViewCntlr.js';
import {DEFAULT_PLOT2D_VIEWER_ID} from '../visualize/VisConst';
import {applyDefaults, flattenAnnotations, formatColExpr, getPointIdx, getRowIdx, handleTableSourceConnections,
- clearChartConn, newTraceFrom, setupTableWatcher, HIGHLIGHTED_PROPS, SELECTED_PROPS, TBL_SRC_PATTERN,
- getSelIndexes, isSpectralOrder, combineAllTraceFrom} from './ChartUtil.js';
+ clearChartConn, newTraceFrom, setupTableWatcher, updateHighlighted, updateSelection, HIGHLIGHTED_PROPS, SELECTED_PROPS, TBL_SRC_PATTERN,
+ getSelIndexes, getTblIdFromChart, isGroupedChart, combineAllTraceFrom, getFilterResetAutorange} from './ChartUtil.js';
import {FilterInfo} from '../tables/FilterInfo.js';
import {SelectInfo} from '../tables/SelectInfo.js';
import {REINIT_APP} from '../core/CoreConst';
import {getAppOptions} from '../core/AppDataCntlr.js';
import {showInfoPopup} from '../ui/PopupUtil.jsx';
-import {makeHistogramParams, makeXYPlotParams, getDefaultChartProps} from './ChartUtil.js';
+import {makeHistogramParams, makeXYPlotParams, getDefaultChartProps, getChartProps} from './ChartUtil.js';
import {adjustColorbars, hasFireflyColorbar} from './dataTypes/FireflyHeatmap.js';
export const CHART_SPACE_PATH = 'charts';
@@ -130,13 +130,14 @@ export function dispatchChartTraceRemove(chartId, traceNum, dispatcher= flux.pro
* @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 {boolean} [p.replaceTableSources=false] - replace and reload the complete table-source set
* @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}});
+export function dispatchChartUpdate({chartId, changes, replaceTableSources=false, dispatcher=flux.process}) {
+ dispatcher({type: CHART_UPDATE, payload: {chartId, changes, replaceTableSources}});
}
/**
@@ -312,19 +313,52 @@ function chartTraceRemove(action) {
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);
- }
+ // mark the cancelled watcher as inactive so it is recreated with the
+ // correct trace index after the trace arrays are reduced.
+ ts._cancel = undefined;
}
});
dispatch(action);
+
+ // Reconnect surviving table sources using their new trace indices;
+ // preserve their metadata, and assign new IDs to sources that moved
+ const {tablesources:remainingSources=[], mounted} = getChartData(chartId) || {};
+ if (mounted > 0 && remainingSources.length > 0) {
+ const reconnectedSources = remainingSources.map((tablesource, idx) => {
+ if (!tablesource?.tbl_id) return tablesource;
+ //give moved sources a new ID so requests from their old index are ignored.
+ const reconnectedSource = idx >= traceNum
+ ? {...tablesource, _sourceId: uniqueId('chart-source-')}
+ : tablesource;
+ return {...reconnectedSource, _cancel: setupTableWatcher(chartId, reconnectedSource, idx)};
+ });
+ dispatchChartUpdate({chartId, changes: {tablesources: reconnectedSources}});
+
+ const activeSource = reconnectedSources[getChartData(chartId)?.activeTrace];
+ const activeTable = activeSource?.tbl_id && getTblById(activeSource.tbl_id);
+ if (activeSource && activeTable) {
+ const currentChart = getChartData(chartId);
+ const activeTraceNow = currentChart.activeTrace;
+ const axisChanges = {};
+ const xMapping = activeSource.mappings?.x;
+ const currentXTitle = currentChart.layout?.xaxis?.title?.text;
+ //restore the shared X-axis label if trace removal left it empty (historgam removal did this, even on ops)
+ if (xMapping && (!currentXTitle || currentXTitle === 'undefined')) {
+ const xColumn = getColumn(activeTable, xMapping);
+ const xUnit = get(xColumn, 'units', '');
+ axisChanges['layout.xaxis.title.text'] = xMapping + (xUnit ? ` (${xUnit})` : '');
+ }
+ if (!isEmpty(axisChanges)) dispatchChartUpdate({chartId, changes: axisChanges});
+ updateHighlighted(chartId, activeTraceNow, activeTable.highlightedRow);
+ updateSelection(chartId, activeTable.selectInfo || {});
+ }
+ }
};
}
function chartUpdate(action) {
return (dispatch) => {
- const {chartId, changes} = action.payload;
+ const {chartId, changes, replaceTableSources=false} = action.payload;
// when selection is undefined, selections layer must be removed
if (changes.hasOwnProperty('selection') && !changes.selection) changes['layout.selections'] = [];
// remove any table's mappings from changes because it will be applied by the connectors.
@@ -339,8 +373,8 @@ function chartUpdate(action) {
// lazy table connection
const {mounted} = getChartData(chartId);
- if (mounted > 0) {
- handleTableSourceConnections({chartId, data, fireflyData});
+ if (mounted > 0 && (!isEmpty(data) || !isEmpty(fireflyData))) {
+ handleTableSourceConnections({chartId, data, fireflyData, replaceTableSources});
}
};
}
@@ -349,7 +383,7 @@ 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 {data, fireflyData, activeTrace:activeDataTrace=0, selected} = getChartData(chartId);
const {traceNum=activeDataTrace, traceName} = action.payload; // highlighted trace can be selected or highlighted trace of the data trace
@@ -359,16 +393,21 @@ function chartHighlight(action) {
const ttype = get(data, [traceNum, 'type'], 'scatter');
- if (!isEmpty(tablesources) && ttype.includes('scatter')) {
+ if (ttype.includes('scatter')) {
// activeTrace is different from activeDataTrace if a selected point highlighted, for example
- const {tbl_id} = tablesources[activeDataTrace] || {};
- if (!tbl_id) return;
+ const tbl_id = getTblIdFromChart(chartId, activeDataTrace);
+ if (!tbl_id || !getTblById(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}});
+ } else if (data[traceNum]) {
+ //update the visual highlight immediately
+ const traceAnnotations = get(fireflyData, `${traceNum}.annotations`);
+ const hlTrace = newTraceFrom(data[traceNum], [highlighted], HIGHLIGHTED_PROPS, traceAnnotations);
+ dispatchChartUpdate({chartId, changes: {highlighted: hlTrace}});
}
let traceData = data[traceNum];
@@ -392,7 +431,7 @@ function chartHighlight(action) {
function chartSelect(action) {
return (dispatch) => {
const {chartId, selIndexes=[], chartTrigger=false} = action.payload;
- const {activeTrace=0, data, fireflyData, tablesources} = getChartData(chartId);
+ const {activeTrace=0, data, fireflyData} = getChartData(chartId);
// when skipping hover, selecting chart points does not work
// disable chart select in this case
@@ -401,18 +440,21 @@ function chartSelect(action) {
// avoid updating chart twice
// don't update before table select
if (chartTrigger) {
- if (!isEmpty(tablesources)) {
- const {tbl_id} = tablesources[activeTrace] || {};
- const {totalRows} = getTblById(tbl_id);
+ const tbl_id = getTblIdFromChart(chartId, activeTrace);
+ const tableModel = tbl_id && getTblById(tbl_id);
+ if (tableModel) {
+ const {totalRows} = tableModel;
const selectInfoCls = SelectInfo.newInstance({rowCount: totalRows});
selIndexes.forEach(([ptIdx, traceIdx]) => selectInfoCls.setRowSelect(getRowIdx(data[traceIdx], ptIdx), true));
TablesCntlr.dispatchTableSelect(tbl_id, selectInfoCls.data);
+ // Keep the chart overlay in sync even while table-source watchers rebuild.
+ dispatchChartSelect({chartId, selIndexes});
}
} else {
let selected = undefined;
const hasSelected = !isEmpty(selIndexes);
- if (isSpectralOrder(chartId)) {
+ if (isGroupedChart(chartId)) {
selected = combineAllTraceFrom(chartId, selIndexes, SELECTED_PROPS);
dispatchChartUpdate({chartId, changes: {hasSelected, selected, selection: undefined}});
} else {
@@ -427,10 +469,19 @@ function chartSelect(action) {
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);
+ const {activeTrace=0, selection, data=[], fireflyData=[]} = getChartData(chartId) || {};
+ const activeTraceType = data?.[activeTrace]?.type;
+ const activeDataType = fireflyData?.[activeTrace]?.dataType;
+
+ //Histograms use bins and counts rather than table-backed X/Y points,so chart filtering does not apply
+ //ignore the action even if a transient selection state or toolbar entry is still present while the trace is loading
+ if (activeDataType === 'fireflyHistogram' || activeTraceType === 'bar') return;
+
+ const tbl_id = getTblIdFromChart(chartId, activeTrace);
+ const tableModel = tbl_id && getTblById(tbl_id);
+ const {mappings={}} = tableModel ? getChartProps(chartId, tbl_id, activeTrace) : {};
+ if (tableModel && selection && mappings.x && mappings.y) {
+ const numericCols = getColumns(tableModel, COL_TYPE.NUMBER).map((c) => c.name);
let {x,y} = mappings;
let upperLimit = get(mappings, `fireflyData.${activeTrace}.yMax`);
@@ -451,7 +502,7 @@ function chartFilterSelection(action) {
}
const [xMin, xMax] = get(selection, 'range.x', []);
const [yMin, yMax] = get(selection, 'range.y', []);
- const {request} = getTblById(tbl_id);
+ const {request} = tableModel;
const filterInfoCls = FilterInfo.parse(request.filters);
filterInfoCls.setFilter(x, '> ' + xMin);
@@ -462,12 +513,19 @@ function chartFilterSelection(action) {
// 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 allColumns = getColumns(tableModel).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}});
+ const {layout} = getChartData(chartId) ?? {};
+ dispatchChartUpdate({chartId, changes:{
+ selection: undefined,
+ 'layout.xaxis.autorange': getFilterResetAutorange(layout?.xaxis),
+ 'layout.xaxis.range': undefined,
+ 'layout.yaxis.autorange': getFilterResetAutorange(layout?.yaxis),
+ 'layout.yaxis.range': undefined
+ }});
}
};
}
@@ -475,14 +533,14 @@ function chartFilterSelection(action) {
function setActiveTrace(action) {
return (dispatch) => {
const {chartId, activeTrace} = action.payload;
- const {data, fireflyData, tablesources, curveNumberMap} = getChartData(chartId);
- const changes = getActiveTraceChanges({chartId, activeTrace, data, fireflyData, tablesources, curveNumberMap});
+ const {data, fireflyData, curveNumberMap} = getChartData(chartId);
+ const changes = getActiveTraceChanges({chartId, activeTrace, data, fireflyData, curveNumberMap});
dispatchChartUpdate({chartId, changes});
};
}
-function getActiveTraceChanges({chartId, activeTrace, data, fireflyData, tablesources, curveNumberMap}) {
- const tbl_id = get(tablesources, [activeTrace, 'tbl_id']);
+function getActiveTraceChanges({chartId, activeTrace, data, fireflyData, curveNumberMap}) {
+ const tbl_id = getTblIdFromChart(chartId, activeTrace);
let selected = undefined;
let highlighted = undefined;
let curveMap = undefined;
@@ -493,7 +551,7 @@ function getActiveTraceChanges({chartId, activeTrace, data, fireflyData, tableso
const selectInfoCls = SelectInfo.newInstance(selectInfo);
const selIndexes = getSelIndexes(data, selectInfoCls, activeTrace);
if (selIndexes.length > 0) {
- if (isSpectralOrder(chartId)) {
+ if (isGroupedChart(chartId)) {
selected = combineAllTraceFrom(chartId, selIndexes, SELECTED_PROPS);
} else {
const traceAnnotations = get(fireflyData, `${activeTrace}.annotations`);
@@ -865,6 +923,17 @@ function removeTrace({chartId, traceNum}) {
}
}
+ //Ensure trace-removal state has a valid active trace and curve map when
+ //an overplot was created before its curve map was initialized
+ //(this is a defensive fix for bugs encountred encountered involving partially-created / asynchronously changing overplots)
+ if (!changes.curveNumberMap && changes.data?.length) {
+ const newActiveTrace = Math.min(activeTrace ?? 0, changes.data.length - 1);
+ changes.activeTrace = newActiveTrace;
+ changes.curveNumberMap = range(changes.data.length)
+ .filter((idx) => idx !== newActiveTrace)
+ .concat(newActiveTrace);
+ }
+
return {changes, moreChanges};
}
@@ -964,4 +1033,3 @@ export function removeChartsInGroup(groupId) {
.filter( (v) => !groupId || v.groupId === groupId)
.forEach( (v) => dispatchChartRemove(v.chartId));
}
-
diff --git a/src/firefly/js/charts/TableStatsCntlr.js b/src/firefly/js/charts/TableStatsCntlr.js
index a32d37ebed..0444a72fb3 100644
--- a/src/firefly/js/charts/TableStatsCntlr.js
+++ b/src/firefly/js/charts/TableStatsCntlr.js
@@ -27,6 +27,9 @@ export const TBLSTATS_DATA_KEY = 'tblstats';
export const LOAD_TBL_STATS = `${TBLSTATS_DATA_KEY}/LOAD_TBL_STATS`;
export const UPDATE_TBL_STATS = `${TBLSTATS_DATA_KEY}/UPDATE_TBL_STATS`;
+//Avoid duplicate column-stat requests while the first request for a table is still pending
+const pendingStatsTblIds = new Set();
+
export default {actionCreators, reducers};
@@ -58,6 +61,8 @@ export function dispatchLoadTblStats(searchRequest, dispatcher= flux.process) {
if (resultSetID !== resultSetIDNow
|| curNumericColCnt !== numericColCnt) { // also reload stats if the number of numeric columns changes.
+ if (pendingStatsTblIds.has(tbl_id)) return;
+ pendingStatsTblIds.add(tbl_id);
dispatcher({type: LOAD_TBL_STATS, payload: {searchRequest}});
}
}
@@ -196,7 +201,14 @@ function fetchTblStats(dispatch, activeTableServerRequest) {
colStats: undefined
}));
}
- );
+ ).finally(() => {
+ pendingStatsTblIds.delete(tbl_id);
+ const currentRequest = getTblById(tbl_id)?.request;
+ const currentResultSetID = get(getTblById(tbl_id), 'tableMeta.resultSetID');
+ if (currentRequest && currentResultSetID !== resultSetIDOnSubmit) {
+ dispatchLoadTblStats(currentRequest);
+ }
+ });
}
export function getColValStats(tblId) {
diff --git a/src/firefly/js/charts/dataTypes/FireflyGenericData.js b/src/firefly/js/charts/dataTypes/FireflyGenericData.js
index c6d46ca7fa..afe94a6962 100644
--- a/src/firefly/js/charts/dataTypes/FireflyGenericData.js
+++ b/src/firefly/js/charts/dataTypes/FireflyGenericData.js
@@ -1,11 +1,12 @@
/*
* License information at https://github.com/Caltech-IPAC/firefly/blob/master/License.txt
*/
-import {get, isArray, isUndefined, uniqueId, isNil} from 'lodash';
+import {get, isArray, isEmpty, isUndefined, uniqueId, isNil} from 'lodash';
import {getTblById, getColumns, getColumn, doFetchTable, stripColumnNameQuotes} from '../../tables/TableUtil.js';
import {cloneRequest, makeSubQueryRequest, MAX_ROW} from '../../tables/TableRequestUtil.js';
+import {getFiltersAsSql} from '../../tables/FilterInfo.js';
import {dispatchChartUpdate, dispatchError, getChartData, getTraceSymbol, hasUpperLimits, hasLowerLimits} from '../ChartsCntlr.js';
-import {formatColExpr, getDataChangesForMappings, updateHighlighted, updateSelection, isScatter2d, getMaxScatterRows, getMinScatterGLRows} from '../ChartUtil.js';
+import {formatColExpr, getDataChangesForMappings, getFilterResetAutorange, getOriginalRowIndexes, isCurrentTableSource, updateHighlighted, updateSelection, isScatter2d, getMaxScatterRows, getMinScatterGLRows} from '../ChartUtil.js';
import {getTraceTSEntries as heatmapTSGetter} from './FireflyHeatmap.js';
import {errorTypeFieldKey} from '../ui/options/Errors.jsx';
@@ -24,7 +25,7 @@ const numberOrArrayProps = ['marker.size'];
export function getTraceTSEntries({traceTS, chartId, traceNum}) {
const {mappings} = traceTS || {};
- if (mappings) {
+ if (!isEmpty(mappings)) {
const options = Object.assign({}, mappings);
if (hasUpperLimits(chartId, traceNum) || hasLowerLimits(chartId, traceNum)) {
@@ -47,7 +48,7 @@ export function getTraceTSEntries({traceTS, chartId, traceNum}) {
async function fetchData(chartId, traceNum, tablesource) {
- const {tbl_id, mappings} = tablesource;
+ const {tbl_id, mappings, resetAxes} = tablesource;
if (!mappings) {
return;
}
@@ -74,10 +75,12 @@ async function fetchData(chartId, traceNum, tablesource) {
const sreq = createChartTblRequest(chartId, traceNum, tablesource);
const tableModel = await doFetchTable(sreq).catch((reason) => {
- dispatchError(chartId, traceNum, reason);
+ if (isCurrentTableSource(chartId, traceNum, tablesource)) dispatchError(chartId, traceNum, reason);
});
- if (tableModel.error) {
+ //ignore stale results from an older table source if this trace was reconnected while fetching
+ if (!isCurrentTableSource(chartId, traceNum, tablesource)) return;
+ if (tableModel?.error) {
return dispatchError(chartId, traceNum, tableModel.error);
}
@@ -87,6 +90,17 @@ async function fetchData(chartId, traceNum, tablesource) {
// extra changes based on trace type
addOtherChanges({changes, chartId, traceNum, tablesource, tableModel: originalTableModel});
+ if (resetAxes) {
+ const {layout} = getChartData(chartId) ?? {};
+ changes['layout.xaxis.autorange'] = getFilterResetAutorange(layout?.xaxis);
+ changes['layout.xaxis.range'] = undefined;
+ changes['layout.yaxis.autorange'] = getFilterResetAutorange(layout?.yaxis);
+ changes['layout.yaxis.range'] = undefined;
+ }
+
+ const rowIdx = getOriginalRowIndexes(tableModel);
+ if (rowIdx) changes[`data.${traceNum}.firefly.rowIdx`] = rowIdx;
+
dispatchChartUpdate({chartId, changes});
const {activeTrace} = getChartData(chartId);
if (isUndefined(activeTrace) || activeTrace === traceNum) {
@@ -120,9 +134,13 @@ export function createChartTblRequest(chartId, traceNum, tablesource) {
}).filter((c, i, a) => a.indexOf(c) === i).// remove duplicates
join(', ') // allows to use the same columns, ex. "w1" as "x", "w1" as "marker.color"
}, true);
- if (fireflyData?.[traceNum]?.filters) {
+ const groupFilter = fireflyData?.[traceNum]?.filters;
+ if (groupFilter) {
const inclCols = (sreq.inclCols ? sreq.inclCols + ',' : '') + '"ROW_NUM" as "ORIG_IDX"';
- sreq = makeSubQueryRequest(request, sreq.title, sreq.params,{filters: fireflyData?.[traceNum].filters, inclCols, pageSize: MAX_ROW});
+ const tableFilter = getFiltersAsSql(tbl_id);
+ //apply the table filter and this trace's group filter together
+ const filters = tableFilter ? `(${tableFilter}) AND (${groupFilter})` : groupFilter;
+ sreq = makeSubQueryRequest(request, sreq.title, sreq.params,{filters, inclCols, pageSize: MAX_ROW});
}
return sreq;
}
@@ -212,7 +230,7 @@ export function addScatterChanges({changes, chartId, traceNum, tablesource, tabl
const colors = get(changes, [`data.${traceNum}.marker.color`]);
let cTipLabel = isArray(colors) ? get(mappings, 'marker.color') : '';
- if (cTipLabel.length > 20) {
+ if (cTipLabel?.length > 20) {
cTipLabel = 'c';
}
diff --git a/src/firefly/js/charts/dataTypes/FireflyHeatmap.js b/src/firefly/js/charts/dataTypes/FireflyHeatmap.js
index f541361e2d..b174157fa3 100644
--- a/src/firefly/js/charts/dataTypes/FireflyHeatmap.js
+++ b/src/firefly/js/charts/dataTypes/FireflyHeatmap.js
@@ -5,7 +5,7 @@ import {get, isArray} from 'lodash';
import {getTblById, getColumn, doFetchTable, stripColumnNameQuotes} from '../../tables/TableUtil.js';
import {makeTableFunctionRequest, MAX_ROW} from '../../tables/TableRequestUtil.js';
import {dispatchChartUpdate, dispatchError, getChartData} from '../ChartsCntlr.js';
-import {isScatter2d, getMaxScatterRows, singleTraceUI, handleBigInt} from '../ChartUtil.js';
+import {isCurrentTableSource, isScatter2d, getMaxScatterRows, singleTraceUI, handleBigInt} from '../ChartUtil.js';
import {serializeDecimateInfo, parseDecimateKey} from '../../tables/Decimate.js';
import BrowserInfo from '../../util/BrowserInfo.js';
import {formatColExpr} from '../ChartUtil.js';
@@ -26,9 +26,13 @@ const DEFBINS = 100;
* @param p.traceNum
*/
export function getTraceTSEntries({traceTS, chartId, traceNum}) {
- const {mappings} = traceTS;
+ const {mappings={}, options:traceOptions={}} = traceTS;
- if (!mappings) return {};
+ //During partial chart updates, trace removal, pinning, filtering, or fullscreen transitions,
+ //the table source could temporarily contain empty mappings, which is why we need this fallback to traceOptions
+ const xColOrExpr = mappings.x || traceOptions.xColOrExpr;
+ const yColOrExpr = mappings.y || traceOptions.yColOrExpr;
+ if (!xColOrExpr || !yColOrExpr) return {};
const {fireflyData, fireflyLayout} = getChartData(chartId) || {};
// server call parameters
@@ -47,9 +51,7 @@ export function getTraceTSEntries({traceTS, chartId, traceNum}) {
const ymax = get(fireflyLayout, 'yaxis.max');
const options = {
- xColOrExpr: get(mappings, 'x'),
- yColOrExpr: get(mappings, 'y'),
- maxbins, xyratio, xmin, xmax, ymin, ymax
+ xColOrExpr, yColOrExpr, maxbins, xyratio, xmin, xmax, ymin, ymax
};
return {options, fetchData};
@@ -75,7 +77,9 @@ function fetchData(chartId, traceNum, tablesource) {
const numericCols = getColumns(tableModel, COL_TYPE.NUMBER).map((c) => c.name);
const {request} = tableModel;
- const {xColOrExpr, yColOrExpr, maxbins, xyratio, xmin, xmax, ymin, ymax} = options;
+ const {xColOrExpr, yColOrExpr, maxbins, xyratio, xmin, xmax, ymin, ymax} = options || {};
+ //A stale/reindexed source can briefly lack axis expressions, do not attempt to fetch in that case
+ if (!xColOrExpr || !yColOrExpr) return;
const xColName = numericCols.includes(xColOrExpr) ? xColOrExpr : 'xColumnExpression';
const asX = (xColName === xColOrExpr) ? '' : ` as "${xColName}"`;
@@ -94,6 +98,9 @@ function fetchData(chartId, traceNum, tablesource) {
{decimate: serializeDecimateInfo(xColName, yColName, maxbins, xyratio, xmin, xmax, ymin, ymax, 0), pageSize: MAX_ROW});
doFetchTable(req).then((tableModel) => {
+ if (!isCurrentTableSource(chartId, traceNum, tablesource)) {
+ return;
+ }
if (tableModel.error) {
dispatchError(chartId, traceNum, tableModel.error);
return;
@@ -111,7 +118,7 @@ function fetchData(chartId, traceNum, tablesource) {
}
}).catch(
(reason) => {
- dispatchError(chartId, traceNum, reason);
+ if (isCurrentTableSource(chartId, traceNum, tablesource)) dispatchError(chartId, traceNum, reason);
}
);
}
@@ -127,9 +134,9 @@ function getChanges({tableModel, tablesource, chartId, traceNum}) {
return {};
}
- // default axes labels for the first trace (remove surrounding quotes, if any)
- const xLabel = stripColumnNameQuotes(get(mappings, 'x'));
- const yLabel = stripColumnNameQuotes(get(mappings, 'y'));
+ //prevent missing source mappings from breaking tooltip and axis-label formatting.
+ const xLabel = stripColumnNameQuotes(mappings?.x || 'x');
+ const yLabel = stripColumnNameQuotes(mappings?.y || 'y');
const xTipLabel = xLabel.length > 20 ? xLabel.substring(0,18)+'...' : xLabel;
const yTipLabel = yLabel.length > 20 ? yLabel.substring(0,18)+'...' : yLabel;
@@ -243,7 +250,11 @@ function getChanges({tableModel, tablesource, chartId, traceNum}) {
if (singleTraceUI() || (data?.length===1)) {
changes[`data.${traceNum}.colorbar.title.text`] = 'pts';
} else {
- changes[`data.${traceNum}.colorbar.title.text`] = get(data, `${traceNum}.name`, 'pts');
+ const traceName = get(data, `${traceNum}.name`);
+ //automatically generated names (for example, "trace 1") are
+ //implementation details and are not useful colorbar titles
+ changes[`data.${traceNum}.colorbar.title.text`] =
+ traceName && !/^trace\s+\d+$/i.test(traceName) ? traceName : 'pts';
}
}
@@ -304,6 +315,14 @@ export function adjustColorbars({data, fireflyData, layout}) {
if (data) {
const changes = {};
const nbars = data.filter((d) => get(d, 'colorbar') && get(d, 'showscale', true)).length;
+ const fireflyColorbarCount = data.filter((d, i) =>
+ get(fireflyData, `${i}.fireflyColorbar`) && get(d, 'colorbar') && get(d, 'showscale', true)).length;
+ if (fireflyColorbarCount === 0 && nbars === 0 && layout?.legend?.orientation === 'h') {
+ //firefly colorbars use a horizontal legend while present. Clear
+ //that temporary override after the last colorbar is removed so
+ //plotly can restore its normal right-side legend placement
+ changes['layout.legend.orientation'] = undefined;
+ }
const yside = get(layout, 'yaxis.side');
const yOpposite = (yside === 'right');
let cnt = 1;
@@ -337,4 +356,4 @@ export function addColorbarChanges(changes, yOpposite, traceNum, x=1.02) {
changes[`data.${traceNum}.colorbar.xanchor`] = 'left';
changes[`data.${traceNum}.colorbar.x`] = x>0 ? x : 1-x;
}
-}
\ No newline at end of file
+}
diff --git a/src/firefly/js/charts/dataTypes/FireflyHistogram.js b/src/firefly/js/charts/dataTypes/FireflyHistogram.js
index 23f5d97567..3802c20d0b 100644
--- a/src/firefly/js/charts/dataTypes/FireflyHistogram.js
+++ b/src/firefly/js/charts/dataTypes/FireflyHistogram.js
@@ -6,7 +6,7 @@ import {logger} from '../../util/Logger.js';
import {COL_TYPE, getColumn, getColumns, getTblById, doFetchTable, stripColumnNameQuotes} from '../../tables/TableUtil.js';
import {cloneRequest, makeTableFunctionRequest, MAX_ROW} from '../../tables/TableRequestUtil.js';
import {dispatchChartUpdate, dispatchError, getChartData} from '../ChartsCntlr.js';
-import {formatColExpr, handleBigInt} from '../ChartUtil.js';
+import {formatColExpr, handleBigInt, isCurrentTableSource} from '../ChartUtil.js';
import {toMaxFixed, getDecimalPlaces} from '../../util/MathUtil.js';
@@ -22,15 +22,20 @@ import Color from '../../util/Color.js';
* @param p.traceNum
* @returns {{options: {}, fetchData: fetchData}}
*/
-export function getTraceTSEntries({chartId, traceNum}) {
+export function getTraceTSEntries({chartId, traceNum, traceTS={}}) {
const options = {};
// server call parameters
const {fireflyData, layout} = getChartData(chartId) || {};
- const histogramParams = get(fireflyData, `${traceNum}.options`);
+ //preserve histogram options when chart state updates are partial
+ const histogramParams = {
+ ...(traceTS.options || {}),
+ ...(fireflyData?.[traceNum]?.options || {})
+ };
+ if (!histogramParams.columnOrExpr) return {};
options.columnExpression = histogramParams.columnOrExpr;
- if (get(layout, 'xaxis.type') === 'log') {
+ if (layout?.xaxis?.type === 'log') {
options.columnExpression = 'lg('+histogramParams.columnOrExpr+')';
}
if (histogramParams.fixedBinSizeSelection) { // fixed size bins
@@ -58,15 +63,28 @@ function fetchData(chartId, traceNum, tablesource) {
const {tbl_id, options} = tablesource;
const tableModel = getTblById(tbl_id);
+ if (!tableModel?.request || !options?.columnExpression) {
+ if (isCurrentTableSource(chartId, traceNum, tablesource)) {
+ dispatchError(chartId, traceNum, 'Histogram table source is incomplete');
+ }
+ return;
+ }
const numericCols = getColumns(tableModel, COL_TYPE.NUMBER).map((c) => c.name);
const {request} = tableModel;
const valueColName = 'columnExpression';
+ const formattedColumnExpression = formatColExpr({
+ colOrExpr: options.columnExpression,
+ quoted: true,
+ colNames: numericCols
+ });
const sreq = cloneRequest(request, {
startIdx: 0,
pageSize: MAX_ROW,
- inclCols: `${formatColExpr({colOrExpr:options.columnExpression, quoted: true, colNames: numericCols})} as "${valueColName}"`,
- sortInfo: `ASC,"${valueColName}"`
+ inclCols: `${formattedColumnExpression} as "${valueColName}"`,
+ //HistogramProcessor expects the selected value as "columnExpression",
+ //but the source query must sort by the original expression
+ sortInfo: `ASC,${formattedColumnExpression}`
});
const sreqTblId = uniqueId(request.tbl_id);
sreq.META_INFO.tbl_id = sreqTblId;
@@ -80,6 +98,8 @@ function fetchData(chartId, traceNum, tablesource) {
doFetchTable(req).then(
(tableModel) => {
+ if (!isCurrentTableSource(chartId, traceNum, tablesource)) return;
+
if (tableModel.error) {
dispatchError(chartId, traceNum, tableModel.error);
return;
@@ -147,7 +167,9 @@ function fetchData(chartId, traceNum, tablesource) {
}
).catch(
(reason) => {
- dispatchError(chartId, traceNum, reason);
+ if (isCurrentTableSource(chartId, traceNum, tablesource)) {
+ dispatchError(chartId, traceNum, reason);
+ }
}
);
}
diff --git a/src/firefly/js/charts/dataTypes/FireflySpectrum.js b/src/firefly/js/charts/dataTypes/FireflySpectrum.js
index 9a0504a221..dc46700c73 100644
--- a/src/firefly/js/charts/dataTypes/FireflySpectrum.js
+++ b/src/firefly/js/charts/dataTypes/FireflySpectrum.js
@@ -1,12 +1,12 @@
/*
* License information at https://github.com/Caltech-IPAC/firefly/blob/master/License.txt
*/
-import {isEmpty, pickBy, cloneDeep, set} from 'lodash';
+import {isEmpty, pickBy, cloneDeep, set, uniqueId} from 'lodash';
import {getTblById, getColumn, doFetchTable, getColumnIdx} from '../../tables/TableUtil.js';
import {getSpectrumDM, REF_POS} from '../../voAnalyzer/SpectrumDM.js';
-import {dispatchChartUpdate, dispatchError} from '../ChartsCntlr.js';
-import {getDataChangesForMappings, updateHighlighted, updateSelection, getMinScatterGLRows, isSpectralOrder} from '../ChartUtil.js';
+import {dispatchChartUpdate, dispatchError, getChartData} from '../ChartsCntlr.js';
+import {getDataChangesForMappings, getFilterResetAutorange, getOriginalRowIndexes, isCurrentTableSource, updateHighlighted, updateSelection, getMinScatterGLRows, isGroupedChart} from '../ChartUtil.js';
import {addOtherChanges, createChartTblRequest, getTraceTSEntries as genericTSGetter} from './FireflyGenericData.js';
import {quoteNonAlphanumeric} from '../../util/expr/Variable.js';
@@ -27,7 +27,7 @@ export function getTraceTSEntries({traceTS, chartId, traceNum}) {
const traceEntry = genericTSGetter({traceTS, chartId, traceNum});
if (isEmpty(traceEntry)) return {};
- if (!isSpectralOrder(chartId)) {
+ if (!isGroupedChart(chartId)) {
return {options: traceEntry.options, fetchData:traceEntry.fetchData};
}
return {options: traceEntry.options, fetchData};
@@ -36,7 +36,7 @@ export function getTraceTSEntries({traceTS, chartId, traceNum}) {
async function fetchData(chartId, traceNum, tablesource) {
- const {tbl_id, mappings} = tablesource;
+ const {tbl_id, mappings, resetAxes} = tablesource;
if (!mappings) {
return;
}
@@ -48,10 +48,12 @@ async function fetchData(chartId, traceNum, tablesource) {
// set(sreq, 'inclCols', (sreq.inclCols + ', "ROW_IDX"');
const tableModel = await doFetchTable(sreq).catch((reason) => {
- dispatchError(chartId, traceNum, reason);
+ if (isCurrentTableSource(chartId, traceNum, tablesource)) dispatchError(chartId, traceNum, reason);
});
- if (tableModel.error) {
+ //ignore stale results from an older table source if this trace was reconnected while fetching
+ if (!isCurrentTableSource(chartId, traceNum, tablesource)) return;
+ if (tableModel?.error) {
return dispatchError(chartId, traceNum, tableModel.error);
}
@@ -61,10 +63,17 @@ async function fetchData(chartId, traceNum, tablesource) {
// extra changes based on trace type
addOtherChanges({changes, chartId, traceNum, tablesource, tableModel: originalTableModel});
+ if (resetAxes) {
+ const {layout} = getChartData(chartId) ?? {};
+ changes['layout.xaxis.autorange'] = getFilterResetAutorange(layout?.xaxis);
+ changes['layout.xaxis.range'] = undefined;
+ changes['layout.yaxis.autorange'] = getFilterResetAutorange(layout?.yaxis);
+ changes['layout.yaxis.range'] = undefined;
+ }
+
// add row_idx to pointIdx mappings
- const origIdx = getColumnIdx(tableModel, 'ORIG_IDX');
- const rowIdx = tableModel.tableData.data.map((row) => row[origIdx]);
- set(changes, [`data.${traceNum}.firefly.rowIdx`], rowIdx);
+ const rowIdx = getOriginalRowIndexes(tableModel);
+ if (rowIdx) set(changes, [`data.${traceNum}.firefly.rowIdx`], rowIdx);
dispatchChartUpdate({chartId, changes});
updateHighlighted(chartId, traceNum, highlightedRow);
@@ -110,7 +119,8 @@ export function spectrumPlot({tbl_id, spectrumDM}) {
const order = cloneDeep(orig);
set(order, 'name', v);
set(order, 'mode', 'lines+markers');
- set(order, 'firefly.spectralOrder', spectralAxis.order);
+ set(order, 'legendgroup', uniqueId('grp'));
+ set(order, 'firefly.groupBy', {column: spectralAxis.order, value: v});
set(order, 'firefly.filters', `"${orderCol.name}" = '${v}'`);
data.push(order);
});
@@ -156,7 +166,7 @@ export function getSpectrumProps(tbl_id, spectrumDM) {
// get default spectral frame and labels, needed to initialize spectrum
// (in future, move redshift processing functions & defaults from SpectrumOptions to a separate file where they can be exported to avoid redundancy)
- const refPos = spectralFrame.refPos.toUpperCase();
+ const refPos = spectralFrame?.refPos?.toUpperCase?.() || REF_POS.TOPOCENTER; //add a fallback to TOPOCENTER if refPos is undefined
const sfLabel = refPos===REF_POS.TOPOCENTER ? 'Observed Frame'
: refPos===REF_POS.CUSTOM ? 'Rest Frame' : `${refPos} Spectral Frame`;
const redshiftLabel = refPos===REF_POS.CUSTOM ? `Custom Redshift = ${spectralFrame.redshift}` : '';
@@ -168,4 +178,4 @@ export function getSpectrumProps(tbl_id, spectrumDM) {
return {spectralAxis, fluxAxis, mode, x, y, xErrArray, xErrArrayMinus, yErrArray, yErrArrayMinus,
xMax, xMin, yMax, yMin, xUnit, yUnit, xLabel, yLabel, isSED, spectralFrame, derivedRedshift, target};
-}
\ No newline at end of file
+}
diff --git a/src/firefly/js/charts/ui/ChartSelectPanel.jsx b/src/firefly/js/charts/ui/ChartSelectPanel.jsx
index 676d6035c0..f9f3c00639 100644
--- a/src/firefly/js/charts/ui/ChartSelectPanel.jsx
+++ b/src/firefly/js/charts/ui/ChartSelectPanel.jsx
@@ -10,7 +10,7 @@ import {useStoreConnector} from './../../ui/SimpleComponent.jsx';
import {getChartData, dispatchChartTraceRemove, dispatchChartUpdate} from '../ChartsCntlr.js';
import {NewTracePanel, getNewTraceType, getSubmitChangesFunc, addNewTrace} from './options/NewTracePanel.jsx';
import {PopupPanel} from './../../ui/PopupPanel.jsx';
-import {isSpectralOrder, isScatter2d, getTblIdFromChart} from '../ChartUtil.js';
+import {isGroupedChart, isScatter2d, getTblIdFromChart} from '../ChartUtil.js';
import {BasicOptions, useBasicOptions} from './options/BasicOptions.jsx';
import {ScatterOptions} from './options/ScatterOptions.jsx';
import {HeatmapOptions} from './options/HeatmapOptions.jsx';
@@ -34,13 +34,13 @@ function getChartActions({chartId, tbl_id}) {
if (data.length > 0) {
// can modify active trace
chartActions.push(CHART_TRACE_MODIFY);
- if (data.length > 1 && !isSpectralOrder(chartId)) {
+ if (data.length > 1 && !isGroupedChart(chartId)) {
// can remove active trace
chartActions.push(CHART_TRACE_REMOVE);
}
}
if (tbl_id) {
- if (!isSpectralOrder(chartId)) {
+ if (!isGroupedChart(chartId)) {
// can add trace
chartActions.push(CHART_TRACE_ADDNEW);
}
@@ -88,26 +88,32 @@ function onChartAction({chartAction, tbl_id, chartId, hideDialog, renderTreeId})
};
}
-function getGroupKey(chartId, chartAction) {
+function getGroupKey(chartId, chartAction, activeTrace) {
if (chartAction === CHART_ADDNEW || chartAction === CHART_TRACE_ADDNEW) {
const type = getNewTraceType();
const cid = (chartAction === CHART_ADDNEW) ? 'newchart' : chartId;
return `${cid}-newtrace-${type}`;
} else {
- const {activeTrace} = getChartData(chartId);
- return `${chartId}-${activeTrace}`;
+ const trace = activeTrace ?? getChartData(chartId)?.activeTrace ?? 0;
+ return `${chartId}-${trace}`;
}
}
export function ChartSelectPanel({tbl_id, chartId, chartAction, inputStyle={}, hideDialog, sx={}}) {
const {renderTreeId} = useContext(RenderTreeIdCtx);
const showActionOptions= chartAction!==CHART_ADDNEW;
+ const isGrouped = useStoreConnector(() => isGroupedChart(chartId), [chartId]);
+ const activeTrace = useStoreConnector(() => getChartData(chartId)?.activeTrace ?? 0, [chartId]);
- const chartActions = showActionOptions ? getChartActions({chartId, tbl_id}) : [CHART_ADDNEW];
+ const chartActions = useStoreConnector(() => showActionOptions ? getChartActions({chartId, tbl_id}) : [CHART_ADDNEW],
+ [chartId, tbl_id, showActionOptions]);
const [chartActionState, setChartActionState] = useState(
(chartActions.includes(chartAction)) ? chartAction : chartActions[0]);
+ useEffect(() => {
+ setChartActionState((current) => chartActions.includes(current) ? current : chartActions[0]);
+ }, [chartActions]);
- const groupKey = getGroupKey(chartId, chartActionState);
+ const groupKey = getGroupKey(chartId, chartActionState, activeTrace);
const chartActionChanged = (chartAction) => setChartActionState(chartAction);
@@ -126,9 +132,9 @@ export function ChartSelectPanel({tbl_id, chartId, chartAction, inputStyle={}, h
}}>