diff --git a/src/components/data-processing/ConfigureEmbedding/ConfigureEmbedding.jsx b/src/components/data-processing/ConfigureEmbedding/ConfigureEmbedding.jsx index 8400df407..7f2a2d3fe 100644 --- a/src/components/data-processing/ConfigureEmbedding/ConfigureEmbedding.jsx +++ b/src/components/data-processing/ConfigureEmbedding/ConfigureEmbedding.jsx @@ -26,6 +26,7 @@ import loadCellMeta from 'redux/actions/cellMeta'; import { generateDataProcessingPlotUuid } from 'utils/generateCustomPlotUuid'; import Loader from 'components/Loader'; import { getCellSets } from 'redux/selectors'; +import { getEmbeddingInitialConfig } from 'utils/plotConfig/getEmbeddingInitialConfig'; import CalculationConfig from 'components/data-processing/ConfigureEmbedding/CalculationConfig'; import PlotLegendAlert, { MAX_LEGEND_ITEMS } from 'components/plots/helpers/PlotLegendAlert'; import EmptyPlot from 'components/plots/helpers/EmptyPlot'; @@ -408,6 +409,35 @@ const ConfigureEmbedding = (props) => { if (showAlert) updatePlotWithChanges({ legend: { showAlert, enabled: !showAlert } }); }, [!selectedConfig, activePlotType, cellSets.accessible]); + // Apply cell-count-aware marker defaults for large datasets in embedding plots + // Only applies once when config is first loaded and hasn't been customized yet + useEffect(() => { + if (!selectedConfig || !cellSets.accessible || plotType !== 'embedding' || !currentPlot) return; + + const initialConfig = getEmbeddingInitialConfig(currentPlot.plotType, cellSets); + + // Check if we should apply large dataset defaults + if (initialConfig.defaultValues?.largeDatasetDefaults) { + // Get the standard (non-adjusted) initial config to check if marker was already customized + const standardConfig = initialPlotConfigStates[currentPlot.plotType]; + + // Only apply if marker config currently matches standard defaults (not yet customized) + const isUsingStandardDefaults = selectedConfig.marker.outline === standardConfig.marker.outline + && selectedConfig.marker.size === standardConfig.marker.size; + + if (isUsingStandardDefaults) { + dispatch(updatePlotConfig(activePlotUuid, { + marker: { + ...selectedConfig.marker, + outline: false, + size: 1, + }, + })); + debounceSave(activePlotUuid); + } + } + }, [activePlotUuid, currentPlot?.plotType, cellSets?.accessible]); + useEffect(() => { // if we change a plot and the config is not saved yet if (outstandingChanges) { @@ -431,8 +461,8 @@ const ConfigureEmbedding = (props) => { embeddingPlotUuids.forEach((plotUuid) => { if (plotUuid !== activePlotUuid) { const otherPlotConfig = plotConfigs[plotUuid]; - if (otherPlotConfig && - JSON.stringify(otherPlotConfig.marker) !== JSON.stringify(selectedConfig.marker)) { + if (otherPlotConfig && + JSON.stringify(otherPlotConfig.marker) !== JSON.stringify(selectedConfig.marker)) { dispatch(updatePlotConfig(plotUuid, { marker: selectedConfig.marker })); } } @@ -447,7 +477,7 @@ const ConfigureEmbedding = (props) => { const plotActions = { export: true, }; - + if (cellSets.accessible && selectedConfig) { setPlot(currentPlot.plot(selectedConfig, plotActions)); } @@ -486,6 +516,8 @@ const ConfigureEmbedding = (props) => { const isEqual = Object.keys(initialConfig).every((key) => { // By pass plot data because we want to compare settings not data if (key === 'plotData') return true; + // Skip defaultValues as it's metadata about defaults, not actual config + if (key === 'defaultValues') return true; if (initialConfig.keepValuesOnReset?.includes(key)) return true; if (currentConfig[key] && typeof currentConfig[key] === 'object' && initialConfig[key] && typeof initialConfig[key] === 'object') { // For nested objects, exclude defaultValues from comparison as it's metadata about defaults @@ -503,12 +535,15 @@ const ConfigureEmbedding = (props) => { useEffect(() => { if (!selectedConfig || !currentPlot) return; - const initialConfig = initialPlotConfigStates[currentPlot.plotType]; + const initialConfig = getEmbeddingInitialConfig(currentPlot.plotType, cellSets); setIsResetDisabled(isConfigEqual(selectedConfig, initialConfig)); - }, [selectedConfig]); + }, [selectedConfig, cellSets]); const onClickReset = () => { - dispatch(resetPlotConfig(experimentId, currentPlot.plotUuid, currentPlot.plotType)); + // For embedding preview plots, use cell-count-aware defaults + const initialConfig = getEmbeddingInitialConfig(currentPlot.plotType, cellSets); + dispatch(updatePlotConfig(currentPlot.plotUuid, initialConfig)); + debounceSave(currentPlot.plotUuid); }; const renderExtraControlPanels = () => ( diff --git a/src/components/data-processing/DataIntegration/DataIntegration.jsx b/src/components/data-processing/DataIntegration/DataIntegration.jsx index 4ab1edaff..ba168c3a8 100644 --- a/src/components/data-processing/DataIntegration/DataIntegration.jsx +++ b/src/components/data-processing/DataIntegration/DataIntegration.jsx @@ -25,6 +25,7 @@ import FrequencyPlot from 'components/plots/FrequencyPlot'; import ElbowPlot from 'components/plots/ElbowPlot'; import { generateDataProcessingPlotUuid } from 'utils/generateCustomPlotUuid'; import EmptyPlot from 'components/plots/helpers/EmptyPlot'; +import { getEmbeddingInitialConfig } from 'utils/plotConfig/getEmbeddingInitialConfig'; import PlotStyling from 'components/plots/styling/PlotStyling'; import { getIsUnisample } from 'utils/experimentPredicates'; import PlotLegendAlert, { MAX_LEGEND_ITEMS } from 'components/plots/helpers/PlotLegendAlert'; @@ -246,6 +247,8 @@ const DataIntegration = (props) => { const isEqual = Object.keys(initialConfig).every((key) => { // By pass plot data because we want to compare settings not data if (key === 'plotData') return true; + // Skip defaultValues as it's metadata about defaults, not actual config + if (key === 'defaultValues') return true; if (initialConfig.keepValuesOnReset?.includes(key)) return true; if (currentConfig[key] && typeof currentConfig[key] === 'object' && initialConfig[key] && typeof initialConfig[key] === 'object') { // For nested objects, exclude defaultValues from comparison as it's metadata about defaults @@ -263,12 +266,15 @@ const DataIntegration = (props) => { useEffect(() => { if (!selectedConfig || !plots[selectedPlot]) return; - const initialConfig = initialPlotConfigStates[activePlotType]; + const initialConfig = getEmbeddingInitialConfig(activePlotType, cellSets); setIsResetDisabled(isConfigEqual(selectedConfig, initialConfig)); - }, [selectedConfig]); + }, [selectedConfig, cellSets]); const onClickReset = () => { - dispatch(resetPlotConfig(experimentId, activePlotUuid, activePlotType)); + // For embedding plots in data integration, use cell-count-aware defaults + const initialConfig = getEmbeddingInitialConfig(activePlotType, cellSets); + dispatch(updatePlotConfig(activePlotUuid, initialConfig)); + debounceSave(activePlotUuid); }; useEffect(() => { @@ -281,6 +287,35 @@ const DataIntegration = (props) => { if (showAlert) updatePlotWithChanges({ legend: { showAlert, enabled: !showAlert } }); }, [!selectedConfig, activePlotType, cellSets.accessible]); + // Apply cell-count-aware marker defaults for large datasets in embedding plots + // Only applies once when config is first loaded and hasn't been customized yet + useEffect(() => { + if (!selectedConfig || !cellSets.accessible || activePlotType !== 'dataIntegrationEmbedding') return; + + const initialConfig = getEmbeddingInitialConfig(activePlotType, cellSets); + + // Check if we should apply large dataset defaults + if (initialConfig.defaultValues?.largeDatasetDefaults) { + // Get the standard (non-adjusted) initial config to check if marker was already customized + const standardConfig = initialPlotConfigStates[activePlotType]; + + // Only apply if marker config currently matches standard defaults (not yet customized) + const isUsingStandardDefaults = selectedConfig.marker.outline === standardConfig.marker.outline + && selectedConfig.marker.size === standardConfig.marker.size; + + if (isUsingStandardDefaults) { + dispatch(updatePlotConfig(activePlotUuid, { + marker: { + ...selectedConfig.marker, + outline: false, + size: 1, + }, + })); + debounceSave(activePlotUuid); + } + } + }, [activePlotUuid, activePlotType, cellSets?.accessible]); + const completedSteps = useSelector(getBackendStatus(experimentId)) .status?.pipeline?.completedSteps; diff --git a/src/components/data-processing/PlotLayout.jsx b/src/components/data-processing/PlotLayout.jsx index 5f30bf5d3..787ee70fe 100644 --- a/src/components/data-processing/PlotLayout.jsx +++ b/src/components/data-processing/PlotLayout.jsx @@ -76,6 +76,8 @@ const PlotLayout = ({ const isEqual = Object.keys(initialConfig).every((key) => { // By pass plot data because we want to compare settings not data if (key === 'plotData') return true; + // Skip defaultValues as it's metadata about defaults, not actual config + if (key === 'defaultValues') return true; if (initialConfig.keepValuesOnReset?.includes(key)) return true; if (currentConfig[key] && typeof currentConfig[key] === 'object' && initialConfig[key] && typeof initialConfig[key] === 'object') { // For nested objects, exclude defaultValues from comparison as it's metadata about defaults diff --git a/src/components/plots/PlotContainer.jsx b/src/components/plots/PlotContainer.jsx index 51bedb75d..4af95c4b5 100644 --- a/src/components/plots/PlotContainer.jsx +++ b/src/components/plots/PlotContainer.jsx @@ -13,9 +13,11 @@ import { resetPlotConfig, savePlotConfig, } from 'redux/actions/componentConfig'; +import { getCellSets } from 'redux/selectors'; import _ from 'lodash'; import PlotStyling from 'components/plots/styling/PlotStyling'; import MultiTileContainer from 'components/MultiTileContainer'; +import { getEmbeddingInitialConfig, isEmbeddingPlotType } from 'utils/plotConfig/getEmbeddingInitialConfig'; const PLOT = 'Plot'; const CONTROLS = 'Controls'; @@ -39,6 +41,7 @@ const PlotContainer = (props) => { const [tileDirection, setTileDirection] = useState(DEFAULT_ORIENTATION); const { config } = useSelector((state) => state.componentConfig[plotUuid] || {}); + const cellSets = useSelector(getCellSets()); const debounceSave = useCallback( _.debounce(() => dispatch(savePlotConfig(experimentId, plotUuid)), saveDebounceTime), [plotUuid], ); @@ -57,6 +60,8 @@ const PlotContainer = (props) => { const isEqual = Object.keys(initialConfig).every((key) => { // By pass plot data because we want to compare settings not data if (key === 'plotData') return true; + // Skip defaultValues as it's metadata about defaults, not actual config + if (key === 'defaultValues') return true; if (initialConfig.keepValuesOnReset?.includes(key)) return true; if (currentConfig[key] && typeof currentConfig[key] === 'object' && initialConfig[key] && typeof initialConfig[key] === 'object') { // For nested objects, exclude defaultValues from comparison as it's metadata about defaults @@ -87,15 +92,63 @@ const PlotContainer = (props) => { debounceSave(); + // For embedding plots with cellSets available, use large-dataset-aware comparison + let initialConfig; + if (isEmbeddingPlotType(plotType) && cellSets?.properties && cellSets?.hierarchy) { + const embeddingConfig = getEmbeddingInitialConfig(plotType, cellSets); + initialConfig = embeddingConfig || initialPlotConfigStates[plotType]; + } else { + initialConfig = initialPlotConfigStates[plotType]; + } + setIsResetDisabled( - isConfigEqual(config, initialPlotConfigStates[plotType]), + isConfigEqual(config, initialConfig), ); - }, [config]); + }, [config, cellSets, plotType]); + + // Auto-apply large-dataset defaults for embedding plots when config first loads with cellSets + useEffect(() => { + if (!isEmbeddingPlotType(plotType) || !config || !cellSets?.properties || !cellSets?.hierarchy) return; + if (config.defaultValues?.largeDatasetDefaults) return; // Already applied + + const cellCount = cellSets.hierarchy?.find((node) => node.key === 'sample') + ?.children?.reduce((sum, child) => { + const cellIds = cellSets.properties[child.key]?.cellIds; + return sum + (cellIds?.size || 0); + }, 0); + + if (cellCount > 100000) { + const largeDatasetConfig = getEmbeddingInitialConfig(plotType, cellSets); + if (largeDatasetConfig && largeDatasetConfig !== initialPlotConfigStates[plotType]) { + dispatch(updatePlotConfig(plotUuid, largeDatasetConfig)); + debounceSave(); + } + } + }, [config, cellSets, plotType, plotUuid, experimentId]); const onClickReset = () => { + // For embedding plots with large datasets, use optimized defaults + if (isEmbeddingPlotType(plotType)) { + const initialConfig = getEmbeddingInitialConfig(plotType, cellSets); + if (initialConfig && initialConfig !== initialPlotConfigStates[plotType]) { + // Preserve fields marked with keepValuesOnReset + const keysToPreserve = initialConfig.keepValuesOnReset || []; + const resetConfig = keysToPreserve.reduce((acc, key) => { + if (config?.[key] !== undefined) { + acc[key] = config[key]; + } + return acc; + }, initialConfig); + + dispatch(updatePlotConfig(plotUuid, resetConfig)); + debounceSave(); + onPlotReset(); + return; + } + } + dispatch(resetPlotConfig(experimentId, plotUuid, plotType)); onPlotReset(); - setIsResetDisabled(true); }; const renderPlotToolbarControls = () => ( diff --git a/src/redux/reducers/componentConfig/initialState.js b/src/redux/reducers/componentConfig/initialState.js index 44485fa9e..65f10e2d8 100644 --- a/src/redux/reducers/componentConfig/initialState.js +++ b/src/redux/reducers/componentConfig/initialState.js @@ -527,7 +527,10 @@ const embeddingPreviewMitochondrialContentInitialConfig = { fontSize: 20, }, fontStyle: fontStyleBaseState, - colour: colourBaseState, + colour: { + ...colourBaseState, + gradient: 'spectral', + }, marker: markerBaseState, labels: labelBaseState, shownGene: null, @@ -559,7 +562,10 @@ const embeddingPreviewDoubletScoreInitialConfig = { fontSize: 20, }, fontStyle: fontStyleBaseState, - colour: colourBaseState, + colour: { + ...colourBaseState, + gradient: 'spectral', + }, marker: markerBaseState, labels: labelBaseState, selectedSample: 'All', @@ -589,7 +595,10 @@ const embeddingPreviewNumOfGenesInitialConfig = { fontSize: 20, }, fontStyle: fontStyleBaseState, - colour: colourBaseState, + colour: { + ...colourBaseState, + gradient: 'spectral', + }, marker: markerBaseState, labels: labelBaseState, selectedSample: 'All', @@ -618,7 +627,10 @@ const embeddingPreviewNumOfUmisInitialConfig = { fontSize: 20, }, fontStyle: fontStyleBaseState, - colour: colourBaseState, + colour: { + ...colourBaseState, + gradient: 'spectral', + }, marker: markerBaseState, labels: labelBaseState, selectedSample: 'All', diff --git a/src/utils/plotConfig/getEmbeddingInitialConfig.js b/src/utils/plotConfig/getEmbeddingInitialConfig.js new file mode 100644 index 000000000..8782d6cc5 --- /dev/null +++ b/src/utils/plotConfig/getEmbeddingInitialConfig.js @@ -0,0 +1,83 @@ +import { initialPlotConfigStates } from 'redux/reducers/componentConfig/initialState'; + +/** + * Gets the total cell count from cellSets + * @param {Object} cellSets - The cellSets object from redux + * @returns {number} Total number of cells + */ +const getTotalCellCount = (cellSets) => { + if (!cellSets?.properties || !cellSets?.hierarchy) { + return 0; + } + + const sampleNode = cellSets.hierarchy.find((node) => node.key === 'sample'); + const totalCells = sampleNode?.children?.reduce((sum, child) => { + const cellIds = cellSets.properties[child.key]?.cellIds; + return sum + (cellIds?.size || 0); + }, 0) || 0; + + return totalCells; +}; + +/** + * Determines if a plot is an embedding plot that should get large-dataset defaults + * Includes both data-processing embedding preview plots and Plots-Tables embedding plots + * @param {string} plotType - The plot type + * @returns {boolean} + */ +const isEmbeddingPlotType = (plotType) => { + const embeddingPlots = [ + // Data-processing embedding preview plots + 'embeddingPreviewBySample', + 'embeddingPreviewByCellSets', + 'embeddingPreviewMitochondrialContent', + 'embeddingPreviewDoubletScore', + 'embeddingPreviewNumOfGenes', + 'embeddingPreviewNumOfUmis', + 'dataIntegrationEmbedding', + // Plots and Tables embedding plots + 'embeddingContinuous', + 'embeddingCategorical', + ]; + return embeddingPlots.includes(plotType); +}; + +/** + * Gets the initial configuration for an embedding plot, adjusted based on cell count. + * For embedding plots with >100k cells: + * - Sets marker.outline to false + * - Sets marker.size to 1 + * + * @param {string} plotType - The plot type + * @param {Object} cellSets - The cellSets object from redux (optional) + * @returns {Object} The initial plot configuration + */ +const getEmbeddingInitialConfig = (plotType, cellSets = null) => { + const baseConfig = { ...initialPlotConfigStates[plotType] }; + + // Apply conditional defaults for embedding plots with large datasets + if (isEmbeddingPlotType(plotType) && cellSets) { + const totalCells = getTotalCellCount(cellSets); + + if (totalCells > 100000) { + return { + ...baseConfig, + marker: { + ...baseConfig.marker, + outline: false, + size: 1, + }, + // Store the conditional defaults info for use in reset logic + defaultValues: { + ...baseConfig.defaultValues, + largeDatasetDefaults: true, + cellCount: totalCells, + }, + }; + } + } + + return baseConfig; +}; + +export { getEmbeddingInitialConfig, getTotalCellCount, isEmbeddingPlotType };