Skip to content

Commit 194c2ea

Browse files
authored
Merge pull request #2324 from broadinstitute/ew-fast-dot-plots
Speed up dot plots: integrate preprocessed data on frontend (SCP-5992)
2 parents 299abd5 + 57c82c0 commit 194c2ea

9 files changed

+795
-18
lines changed

app/javascript/components/visualization/DotPlot.jsx

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ import { withErrorBoundary } from '~/lib/ErrorBoundary'
1111
import LoadingSpinner, { morpheusLoadingSpinner } from '~/lib/LoadingSpinner'
1212
import { fetchServiceWorkerCache } from '~/lib/service-worker-cache'
1313
import { getSCPContext } from '~/providers/SCPContextProvider'
14+
import { getFeatureFlagsWithDefaults } from '~/providers/UserProvider'
15+
import '~/lib/dot-plot-precompute-patch'
1416

1517
export const dotPlotColorScheme = {
1618
// Blue, purple, red. These red and blue hues are accessible, per WCAG.
1719
colors: ['#0000BB', '#CC0088', '#FF0000'],
18-
1920
// TODO: Incorporate expression units, once such metadata is available.
20-
values: [0, 0.5, 1]
21+
values: [0, 0.5, 1],
22+
scalingMode: 'relative'
2123
}
2224

2325
/**
@@ -199,14 +201,19 @@ function RawDotPlot({
199201
useEffect(() => {
200202
/** Fetch Morpheus data for dot plot */
201203
async function getDataset() {
204+
const flags = getFeatureFlagsWithDefaults()
205+
const usePrecomputed = flags?.dot_plot_preprocessing_frontend || false
206+
202207
const [dataset, perfTimes] = await fetchMorpheusJson(
203208
studyAccession,
204209
genes,
205210
cluster,
206211
annotation.name,
207212
annotation.type,
208213
annotation.scope,
209-
subsample
214+
subsample,
215+
false, // mock
216+
usePrecomputed // isPrecomputed
210217
)
211218
logFetchMorpheusDataset(perfTimes, cluster, annotation, genes)
212219

@@ -265,8 +272,19 @@ export function renderDotPlot({
265272
const $target = $(target)
266273
$target.empty()
267274

268-
// Collapse by mean
269-
const tools = [{
275+
// Check if dataset is pre-computed dot plot data
276+
// Pre-computed data has structure: { annotation_name, values, genes }
277+
let processedDataset = dataset
278+
let isPrecomputed = false
279+
280+
if (dataset && dataset.annotation_name && dataset.values && dataset.genes) {
281+
// This is pre-computed dot plot data - convert it using the patch
282+
processedDataset = window.createMorpheusDotPlot(dataset)
283+
isPrecomputed = true
284+
}
285+
286+
// Collapse by mean (only for non-precomputed data)
287+
const tools = isPrecomputed ? [] : [{
270288
name: 'Collapse',
271289
params: {
272290
collapse_method: 'Mean',
@@ -275,30 +293,37 @@ export function renderDotPlot({
275293
collapse_to_fields: [annotationName],
276294
pass_expression: '>',
277295
pass_value: '0',
278-
percentile: '100',
296+
percentile: '75',
279297
compute_percent: true
280298
}
281299
}]
282300

283301
const config = {
284302
shape: 'circle',
285-
dataset,
303+
dataset: processedDataset,
286304
el: $target,
287305
menu: null,
288306
error: morpheusErrorHandler($target, setShowError, setErrorContent),
289-
colorScheme: {
290-
scalingMode: 'relative'
291-
},
292307
focus: null,
293308
tabManager: morpheusTabManager($target),
294309
tools,
295310
loadedCallback: () => logMorpheusPerfTime(target, 'dotplot', genes)
296311
}
297312

313+
// For pre-computed data, tell Morpheus to display series 0 for color
314+
// and use series 1 for sizing (which happens automatically with shape: 'circle')
315+
if (isPrecomputed) {
316+
config.symmetricColorScheme = false
317+
// Tell Morpheus which series to use for coloring the heatmap
318+
config.seriesIndex = 0 // Display series 0 (Mean Expression) for colors
319+
// Explicitly set the size series
320+
config.sizeBySeriesIndex = 1 // Use series 1 (__count) for sizing
321+
}
322+
298323
// Load annotations if specified
299-
config.columnSortBy = [
300-
{ field: annotationName, order: 0 }
301-
]
324+
// config.columnSortBy = [
325+
// { field: annotationName, order: 0 }
326+
// ]
302327
config.columns = [
303328
{ field: annotationName, display: 'text' }
304329
]
@@ -319,7 +344,28 @@ export function renderDotPlot({
319344
config.columnColorModel = annotColorModel
320345

321346

322-
config.colorScheme = dotPlotColorScheme
347+
// Set color scheme (will be overridden for precomputed data below)
348+
if (!isPrecomputed) {
349+
config.colorScheme = dotPlotColorScheme
350+
}
351+
352+
// For precomputed data, configure the sizer to use the __count series
353+
if (isPrecomputed && processedDataset) {
354+
// The color scheme should already have a sizer - we just need to configure it
355+
config.sizeBy = {
356+
seriesName: 'percent',
357+
min: 0,
358+
max: 75
359+
}
360+
361+
// Use relative color scheme for raw expression values
362+
// This will scale colors based on the actual data range across all genes and cell types
363+
config.colorScheme = {
364+
colors: ['#0000BB', '#CC0088', '#FF0000'],
365+
values: [0, 0.5, 1],
366+
scalingMode: 'relative'
367+
}
368+
}
323369

324370
patchServiceWorkerCache()
325371

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
/**
2+
* Monkeypatch for Morpheus to accept pre-computed dot plot data
3+
* Data format: [mean_expression, percent_expressing]
4+
* Mean expression values are normalized per-gene (per-row) to 0-1 range for proper color scaling
5+
*/
6+
7+
(function() {
8+
'use strict'
9+
10+
/**
11+
* Apply the dot plot patch to Morpheus once it's loaded
12+
* Waits for window.morpheus to be available before patching
13+
*/
14+
function applyDotPlotPatch() {
15+
if (typeof window.morpheus === 'undefined') {
16+
// Morpheus not loaded yet, wait a bit and try again
17+
setTimeout(applyDotPlotPatch, 100)
18+
return
19+
}
20+
21+
/**
22+
* Convert your dot plot JSON format to a Morpheus dataset
23+
*/
24+
window.morpheus.DotPlotConverter = {
25+
26+
createDataset(data) {
27+
const cellTypes = data.values
28+
const geneNames = Object.keys(data.genes)
29+
const nRows = geneNames.length
30+
const nCols = cellTypes.length
31+
32+
// Create dataset with Float32 data type
33+
// The dataset name becomes the first series name by default
34+
const dataset = new window.morpheus.Dataset({
35+
name: 'Mean Expression',
36+
rows: nRows,
37+
columns: nCols,
38+
dataType: 'Float32'
39+
})
40+
41+
// Add second series for the size metric (percent expressing)
42+
// Morpheus uses 'percent' for sizing in dot plots
43+
dataset.addSeries({
44+
name: 'percent',
45+
dataType: 'Float32'
46+
})
47+
48+
// Set up row metadata (genes)
49+
const rowIds = dataset.getRowMetadata().add('id')
50+
geneNames.forEach((gene, i) => {
51+
rowIds.setValue(i, gene)
52+
})
53+
54+
// Set up column metadata (cell types)
55+
const colIds = dataset.getColumnMetadata().add('id')
56+
const cellTypeMetadata = dataset.getColumnMetadata().add(data.annotation_name || 'Cell Type')
57+
cellTypes.forEach((cellType, j) => {
58+
colIds.setValue(j, cellType)
59+
cellTypeMetadata.setValue(j, cellType)
60+
})
61+
62+
// Fill in the data
63+
// Series 0: mean expression (for color) - will be normalized per-gene (row)
64+
// Series 1: percent expressing (for size) - will be scaled to 0-100
65+
// Data format: values[0] = mean_expression, values[1] = percent_expressing
66+
geneNames.forEach((gene, i) => {
67+
const geneData = data.genes[gene]
68+
69+
geneData.forEach((values, j) => {
70+
const meanExpression = values[0]
71+
const percentExpressing = values[1]
72+
73+
// Use raw mean expression values, but convert zeros to NaN
74+
// This excludes them from Morpheus color scaling while preserving actual values
75+
const expressionValue = meanExpression === 0 ? NaN : meanExpression
76+
dataset.setValue(i, j, expressionValue, 0) // Raw mean expression for color (zeros as NaN)
77+
// Scale percent expressing to 0-100 range for better sizing
78+
dataset.setValue(i, j, percentExpressing * 100, 1) // Percent expressing (0-100) for size
79+
})
80+
})
81+
82+
return dataset
83+
},
84+
85+
/**
86+
* Add custom properties to enable dot plot mode
87+
*/
88+
configureDotPlot(dataset) {
89+
// Add a property to indicate this is dot plot data
90+
dataset._isDotPlot = true
91+
dataset._dotPlotSizeSeries = 1 // Percent expressing
92+
dataset._dotPlotColorSeries = 0 // Mean expression
93+
94+
return dataset
95+
}
96+
}
97+
98+
/**
99+
* Register a custom JSON reader for dot plot format
100+
*/
101+
const OriginalJsonReader = window.morpheus.JsonDatasetReader
102+
103+
window.morpheus.JsonDatasetReader = function() {
104+
OriginalJsonReader.call(this)
105+
}
106+
107+
window.morpheus.JsonDatasetReader.prototype = Object.create(OriginalJsonReader.prototype)
108+
109+
const originalRead = OriginalJsonReader.prototype.read
110+
window.morpheus.JsonDatasetReader.prototype.read = function(fileOrUrl, callback) {
111+
const self = this
112+
113+
// Check if it's our dot plot format
114+
window.morpheus.Util.getText(fileOrUrl).then(text => {
115+
try {
116+
const data = JSON.parse(text)
117+
118+
// Check if it matches our dot plot format
119+
if (data.annotation_name && data.values && data.genes) {
120+
let dataset = window.morpheus.DotPlotConverter.createDataset(data)
121+
dataset = window.morpheus.DotPlotConverter.configureDotPlot(dataset)
122+
callback(null, dataset)
123+
} else {
124+
// Fall back to original reader
125+
originalRead.call(self, fileOrUrl, callback)
126+
}
127+
} catch (err) {
128+
callback(err)
129+
}
130+
}).catch(err => {
131+
callback(err)
132+
})
133+
}
134+
135+
/**
136+
* Helper to create dot plot directly from your data object
137+
*/
138+
window.createMorpheusDotPlot = function(data) {
139+
const dataset = window.morpheus.DotPlotConverter.createDataset(data)
140+
return window.morpheus.DotPlotConverter.configureDotPlot(dataset)
141+
}
142+
143+
/**
144+
* Patch the HeatMap to properly handle dot plot sizing with __count series
145+
*/
146+
const OriginalHeatMap = window.morpheus.HeatMap
147+
window.morpheus.HeatMap = function(options) {
148+
const heatmap = new OriginalHeatMap(options)
149+
150+
// Check if this is a precomputed dot plot dataset
151+
if (options.dataset && options.dataset._isDotPlot) {
152+
// Force the heatmap to use series 1 for sizing
153+
if (heatmap.heatMapElementCanvas) {
154+
heatmap.heatMapElementCanvas.sizeBySeriesIndex = 1
155+
}
156+
}
157+
158+
return heatmap
159+
}
160+
161+
// Copy static properties
162+
Object.setPrototypeOf(window.morpheus.HeatMap, OriginalHeatMap)
163+
window.morpheus.HeatMap.prototype = OriginalHeatMap.prototype
164+
}
165+
166+
// Start trying to apply the patch
167+
applyDotPlotPatch()
168+
})()

app/javascript/lib/scp-api.jsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -750,7 +750,8 @@ export async function fetchMorpheusJson(
750750
annotationType,
751751
annotationScope,
752752
subsample,
753-
mock=false
753+
mock=false,
754+
usePrecomputed=true
754755
) {
755756
let geneString = genes
756757
if (Array.isArray(genes)) {
@@ -764,7 +765,8 @@ export async function fetchMorpheusJson(
764765
subsample,
765766
genes: geneString
766767
}
767-
const apiUrl = `/studies/${studyAccession}/expression/morpheus${stringifyQuery(paramObj)}`
768+
const endpoint = usePrecomputed ? 'dotplot' : 'morpheus'
769+
const apiUrl = `/studies/${studyAccession}/expression/${endpoint}${stringifyQuery(paramObj)}`
768770
// don't camelcase the keys since those can be cluster names,
769771
// so send false for the 4th argument
770772
const [violin, perfTimes] = await scpApi(apiUrl, defaultInit(), mock, false)

db/migrate/20250609182851_make_all_facets_mongo_based.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
class MakeAllFacetsMongoBased < Mongoid::Migration
22
def self.up
33
SearchFacet.update_all(is_mongo_based: true)
4-
CellMetadatum.where(name: 'organism_age').map(&:set_minmax_by_units!)
4+
# Only process CellMetadatum records that have a valid study association
5+
CellMetadatum.where(name: 'organism_age').each do |cell_metadatum|
6+
next if cell_metadatum.study.nil?
7+
cell_metadatum.set_minmax_by_units!
8+
end
59
end
610

711
def self.down

db/migrate/20250616192718_create_dot_plot_gene_collection.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ class CreateDotPlotGeneCollection < Mongoid::Migration
22
def self.up
33
# since these documents will be created by scp-ingest-pipeline, the collection needs to exist first to
44
# prevent errors when the job tries to create them
5-
DotPlotGene.collection.create
5+
# Only create if it doesn't already exist
6+
DotPlotGene.collection.create unless DotPlotGene.collection.database.collection_names.include?('dot_plot_genes')
67
end
78

89
def self.down
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
class AddDotPlotPreprocessingFrontendFeatureFlag < Mongoid::Migration
2+
def self.up
3+
FeatureFlag.find_or_create_by(name: 'dot_plot_preprocessing_frontend') do |flag|
4+
flag.default_value = false
5+
flag.description = 'Enable pre-computed dot plot data from backend preprocessing'
6+
end
7+
end
8+
9+
def self.down
10+
flag = FeatureFlag.find_by(name: 'dot_plot_preprocessing_frontend')
11+
flag.destroy if flag.present?
12+
end
13+
end

0 commit comments

Comments
 (0)