Skip to content

Commit 4303754

Browse files
authored
Merge pull request #2248 from broadinstitute/ew-refine-pathways
Robustify pathway search and visualization (SCP-5990)
2 parents 880f9ae + def2112 commit 4303754

File tree

7 files changed

+78
-55
lines changed

7 files changed

+78
-55
lines changed

app/javascript/components/explore/ExploreDisplayPanelManager.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ import BookmarkManager from '~/components/bookmarks/BookmarkManager'
2525
import { EXPRESSION_SORT_OPTIONS } from '~/components/visualization/PlotDisplayControls'
2626

2727
/** Get the selected clustering and annotation, or their defaults */
28-
function getSelectedClusterAndAnnot(exploreInfo, exploreParams) {
28+
export function getSelectedClusterAndAnnot(exploreInfo, exploreParams) {
29+
if (!exploreInfo) {return [null, null]}
2930
const annotList = exploreInfo.annotationList
3031
let selectedCluster
3132
let selectedAnnot

app/javascript/components/explore/ExploreDisplayTabs.jsx

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import useResizeEffect from '~/hooks/useResizeEffect'
2323
import LoadingSpinner from '~/lib/LoadingSpinner'
2424
import { log } from '~/lib/metrics-api'
2525
import { getFeatureFlagsWithDefaults } from '~/providers/UserProvider'
26-
import ExploreDisplayPanelManager from './ExploreDisplayPanelManager'
26+
import ExploreDisplayPanelManager, {getSelectedClusterAndAnnot} from './ExploreDisplayPanelManager'
2727
import OverlayTrigger from 'react-bootstrap/lib/OverlayTrigger'
2828
import Tooltip from 'react-bootstrap/lib/Tooltip'
2929
import PlotTabs from './PlotTabs'
@@ -32,22 +32,6 @@ import {
3232
} from '~/lib/cell-faceting'
3333
import { getIsPathway } from '~/lib/search-utils'
3434

35-
/** Get the selected clustering and annotation, or their defaults */
36-
export function getSelectedClusterAndAnnot(exploreInfo, exploreParams) {
37-
if (!exploreInfo) {return [null, null]}
38-
const annotList = exploreInfo.annotationList
39-
let selectedCluster
40-
let selectedAnnot
41-
if (exploreParams?.cluster) {
42-
selectedCluster = exploreParams.cluster
43-
selectedAnnot = exploreParams.annotation
44-
} else {
45-
selectedCluster = annotList.default_cluster
46-
selectedAnnot = annotList.default_annotation
47-
}
48-
49-
return [selectedCluster, selectedAnnot]
50-
}
5135

5236
/** Determine if current annotation has one-vs-rest or pairwise DE */
5337
function getHasComparisonDe(exploreInfo, exploreParams, comparison) {
@@ -539,6 +523,9 @@ export default function ExploreDisplayTabs({
539523
queries = exploreParams.genes
540524
}
541525

526+
// eslint-disable-next-line no-unused-vars
527+
const [_, selectedAnnotation] = getSelectedClusterAndAnnot(exploreInfo, exploreParams)
528+
542529
return (
543530
<>
544531
{/* Render top content for Explore view, i.e. gene search box and plot tabs */}
@@ -550,7 +537,9 @@ export default function ExploreDisplayTabs({
550537
queryFn={queryFn}
551538
allGenes={exploreInfo ? exploreInfo.uniqueGenes : []}
552539
isLoading={!exploreInfo}
553-
speciesList={exploreInfo ? exploreInfo.taxonNames : []}/>
540+
speciesList={exploreInfo ? exploreInfo.taxonNames : []}
541+
selectedAnnotation={selectedAnnotation}
542+
/>
554543
{ // show if this is gene search || gene list
555544
(isGene || isGeneList || hasIdeogramOutputs || isPathway) &&
556545
<OverlayTrigger placement="top" overlay={

app/javascript/components/explore/StudyGeneField.jsx

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@ function getIsPartialPathwayMatch(query, allGenes) {
4141
}
4242

4343
/** Parse gene name from heterogeneous array */
44-
function getQueriesFromSearchOptions(newQueryArray, speciesList) {
44+
function getQueriesFromSearchOptions(newQueryArray, speciesList, selectedAnnotation) {
4545
let newQueries
46-
if (newQueryArray[0]?.isGene === true || !getIsEligibleForPathwayExplore(speciesList)) {
46+
if (newQueryArray[0]?.isGene === true || !getIsEligibleForPathwayExplore(speciesList, selectedAnnotation)) {
4747
// Query is a gene
4848
newQueries = newQueryArray.map(g => g.value)
4949
} else if (newQueryArray.length === 0) {
@@ -64,19 +64,20 @@ function getQueriesFromSearchOptions(newQueryArray, speciesList) {
6464
}
6565

6666
/** Indicate whether pathway view should be available for this study */
67-
function getIsEligibleForPathwayExplore(speciesList) {
67+
export function getIsEligibleForPathwayExplore(speciesList, selectedAnnotation) {
6868
const isEligibleForPathwayExplore = (
6969
speciesList.length === 1 && speciesList[0] === 'Homo sapiens' &&
70+
selectedAnnotation.type === 'group' &&
7071
getFeatureFlagsWithDefaults()?.show_pathway_expression
7172
)
7273
return isEligibleForPathwayExplore
7374
}
7475

7576
/** Collapse search options to query array */
76-
function getQueryArrayFromSearchOptions(searchOptions, speciesList) {
77+
function getQueryArrayFromSearchOptions(searchOptions, speciesList, selectedAnnotation) {
7778
let queryArray = []
7879

79-
if (!getIsEligibleForPathwayExplore(speciesList)) {
80+
if (!getIsEligibleForPathwayExplore(speciesList, selectedAnnotation)) {
8081
return searchOptions
8182
}
8283

@@ -98,17 +99,19 @@ function getQueryArrayFromSearchOptions(searchOptions, speciesList) {
9899
* @param allGenes String array of valid genes in the study
99100
* @param speciesList String array of species scientific names
100101
*/
101-
export default function StudyGeneField({ queries, queryFn, allGenes, speciesList, isLoading=false }) {
102+
export default function StudyGeneField({
103+
queries, queryFn, allGenes, speciesList, selectedAnnotation, isLoading=false
104+
}) {
102105
const [inputText, setInputText] = useState('')
103106

104-
const rawSuggestions = getAutocompleteSuggestions(inputText, allGenes)
105-
const searchOptions = getSearchOptions(rawSuggestions, speciesList)
106-
107+
const includePathways = getIsEligibleForPathwayExplore(speciesList, selectedAnnotation)
108+
const rawSuggestions = getAutocompleteSuggestions(inputText, allGenes, includePathways)
109+
const searchOptions = getSearchOptions(rawSuggestions, speciesList, selectedAnnotation)
107110

108111
let enteredQueryArray = []
109112
if (inputText.length === 0 && queries && queries.length > 0) {
110-
const queriesSearchOptions = getSearchOptions(queries, speciesList)
111-
enteredQueryArray = getQueryArrayFromSearchOptions(queriesSearchOptions, speciesList)
113+
const queriesSearchOptions = getSearchOptions(queries, speciesList, selectedAnnotation)
114+
enteredQueryArray = getQueryArrayFromSearchOptions(queriesSearchOptions, speciesList, selectedAnnotation)
112115
} else {
113116
enteredQueryArray = searchOptions
114117
}
@@ -129,7 +132,7 @@ export default function StudyGeneField({ queries, queryFn, allGenes, speciesList
129132

130133
const newNotPresentQueries = new Set([])
131134
if (newQueryArray) {
132-
if (!getIsEligibleForPathwayExplore(speciesList)) {
135+
if (!getIsEligibleForPathwayExplore(speciesList, selectedAnnotation)) {
133136
newQueryArray.map(g => g.value).forEach(query => {
134137
// if an entered gene is not in the valid gene options for the study
135138
const isInvalidQuery = getIsInvalidQuery(query, allGenes)
@@ -138,7 +141,7 @@ export default function StudyGeneField({ queries, queryFn, allGenes, speciesList
138141
}
139142
})
140143
} else {
141-
const newQueries = getQueriesFromSearchOptions(newQueryArray, speciesList)
144+
const newQueries = getQueriesFromSearchOptions(newQueryArray, speciesList, selectedAnnotation)
142145
newQueries.forEach(query => {
143146
// if an entered gene is not in the valid gene options for the study
144147
const isInvalidQuery = getIsInvalidQuery(query, allGenes)
@@ -153,7 +156,7 @@ export default function StudyGeneField({ queries, queryFn, allGenes, speciesList
153156
if (newNotPresentQueries.size > 0) {
154157
setShowNotPresentGeneChoice(true)
155158
} else if (newQueryArray && newQueryArray.length) {
156-
const newQueries = getQueriesFromSearchOptions(newQueryArray, speciesList)
159+
const newQueries = getQueriesFromSearchOptions(newQueryArray, speciesList, selectedAnnotation)
157160
const queries = newQueries
158161
if (queries.length > window.MAX_GENE_SEARCH) {
159162
log('search-too-many-genes', { numGenes: queries.length })
@@ -178,7 +181,7 @@ export default function StudyGeneField({ queries, queryFn, allGenes, speciesList
178181
return queryArray
179182
}
180183
}
181-
const searchOptions = getSearchOptions(inputTextValues, speciesList)
184+
const searchOptions = getSearchOptions(inputTextValues, speciesList, selectedAnnotation)
182185
const queryOptions = searchOptions[0].options
183186
const newQueryArray = queryArray.concat(queryOptions)
184187
setInputText('')
@@ -226,8 +229,8 @@ export default function StudyGeneField({ queries, queryFn, allGenes, speciesList
226229
useEffect(() => {
227230
if (queries.join(',') !== queryArray.map(opt => opt.value).join(',')) {
228231
// the genes have been updated elsewhere -- resync
229-
const queriesSearchOptions = getSearchOptions(queries, speciesList)
230-
const newQueryArray = getQueryArrayFromSearchOptions(queriesSearchOptions, speciesList)
232+
const queriesSearchOptions = getSearchOptions(queries, speciesList, selectedAnnotation)
233+
const newQueryArray = getQueryArrayFromSearchOptions(queriesSearchOptions, speciesList, selectedAnnotation)
231234
setQueryArray(newQueryArray)
232235
setInputText('')
233236
setNotPresentQueries(new Set([]))
@@ -354,7 +357,7 @@ export default function StudyGeneField({ queries, queryFn, allGenes, speciesList
354357
/** Last filtering applied before showing selectable autocomplete options */
355358
function finalFilterOptions(option, rawInput) {
356359
const input = rawInput.toLowerCase()
357-
const label = option.label.toLowerCase()
360+
const label = 'label' in option ? option.label.toLowerCase() : option.toLowerCase()
358361
const isPathway = option.data.isGene === false
359362
return isPathway || label.includes(input) // partial match
360363
}
@@ -376,8 +379,8 @@ function filterSearchOptions(rawGeneOptions, rawPathwayOptions) {
376379
}
377380

378381
/** takes an array of gene name strings, and returns options suitable for react-select */
379-
function getSearchOptions(rawSuggestions, speciesList) {
380-
if (!getIsEligibleForPathwayExplore(speciesList)) {
382+
function getSearchOptions(rawSuggestions, speciesList, selectedAnnotation) {
383+
if (!getIsEligibleForPathwayExplore(speciesList, selectedAnnotation)) {
381384
return rawSuggestions.map(rawSuggestion => {
382385
const geneName = rawSuggestion
383386
return { label: geneName, value: geneName, isGene: true }

app/javascript/components/visualization/Pathway.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, { useState, useEffect } from 'react'
22
import { Popover, OverlayTrigger } from 'react-bootstrap'
3-
import { manageDrawPathway } from '~/lib/pathway-expression'
3+
import { manageDrawPathway, writeLoadingIndicator } from '~/lib/pathway-expression'
44
import { getIdentifierForAnnotation, naturalSort } from '~/lib/cluster-utils'
55
import { round } from '~/lib/metrics-perf'
66

@@ -136,6 +136,7 @@ export default function Pathway({
136136
moveDescription()
137137
document.addEventListener('ideogramDrawPathway', configurePathwayTooltips)
138138
document.addEventListener('ideogramDrawPathway', updatePathwayOntologies)
139+
document.addEventListener('ideogramDrawPathway', writeLoadingIndicator)
139140

140141
/** Upon clicking a pathway node, show new pathway and expression overlay */
141142
function handlePathwayNodeClick(event, pathwayId) {

app/javascript/lib/pathway-expression.js

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ window.onerror = function(error) {
3232
}
3333
}
3434

35+
const loadingCls = 'pathway-loading-expression'
36+
3537
/**
3638
* Get mean, percent, and color per gene, by annotation label
3739
*
@@ -279,7 +281,7 @@ function writePathwayAnnotationLabelMenu(label, dotPlotMetrics) {
279281
}
280282

281283
/** Update pathway header with SCP label menu, info icon */
282-
function writePathwayExpressionHeader(loadingCls, dotPlotMetrics, label, pathwayGenes) {
284+
function writePathwayExpressionHeader(dotPlotMetrics, label, pathwayGenes) {
283285
// Remove "Loading expression...", as load is done
284286
document.querySelector(`.${loadingCls}`)?.remove()
285287

@@ -288,9 +290,12 @@ function writePathwayExpressionHeader(loadingCls, dotPlotMetrics, label, pathway
288290
}
289291

290292
/** Add "Loading expression..." to pathway header while dot plot metrics are being fetched */
291-
function writeLoadingIndicator(loadingCls) {
293+
export function writeLoadingIndicator() {
292294
document.querySelector('.pathway-label-menu-container')?.remove()
293295
const headerLink = document.querySelector('._ideoPathwayHeader a')
296+
if (!headerLink) {
297+
return
298+
}
294299
const style = 'color: #777; font-style: italic; margin-left: 10px;'
295300
const loading = `<span class="${loadingCls}" style="${style}">Loading expression...</span>`
296301
document.querySelector(`.${loadingCls}`)?.remove()
@@ -326,9 +331,6 @@ export async function renderPathwayExpression(studyAccession, cluster, annotatio
326331
return
327332
}
328333

329-
const loadingCls = 'pathway-loading-expression'
330-
writeLoadingIndicator(loadingCls)
331-
332334
/** After invisible dot plot renders, color each gene by expression metrics */
333335
function backgroundDotPlotDrawCallback(dotPlot) {
334336
// The first render is for uncollapsed cell-x-gene metrics (heatmap),
@@ -353,11 +355,11 @@ export async function renderPathwayExpression(studyAccession, cluster, annotatio
353355

354356
allDotPlotMetrics = mergeDotPlotMetrics(dotPlotMetrics, allDotPlotMetrics)
355357

356-
writePathwayExpressionHeader(loadingCls, allDotPlotMetrics, label, pathwayGenes)
358+
writePathwayExpressionHeader(allDotPlotMetrics, label, pathwayGenes)
357359

358360
colorPathwayGenesByExpression(label, allDotPlotMetrics)
359361

360-
if (numRenders <= dotPlotGeneBatches.length) {
362+
if (numRenders < dotPlotGeneBatches.length - 1) {
361363
numRenders += 1
362364
// Future optimization: render background dot plot one annotation at a time. This would
363365
// speed up initial pathway expression overlay rendering, and increase the practical limit

app/javascript/lib/search-utils.js

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -182,9 +182,9 @@ function getPathwaySuggestions(inputText, maxPathwaySuggestions) {
182182
*
183183
* @param {String} inputString String typed by user into text input
184184
* @param {Array<String>} targets List of strings to match against
185-
* @param {Integer} numSuggestions Number of suggestions to show
185+
* @param {Boolean} includePathways Whether include pathway suggestions
186186
*/
187-
export function getAutocompleteSuggestions(inputText, targets, numSuggestions=NUM_SUGGESTIONS) {
187+
export function getAutocompleteSuggestions(inputText, targets, includePathways) {
188188
// Autocomplete when user starts typing
189189
if (!targets?.length || !inputText) {
190190
return []
@@ -203,7 +203,7 @@ export function getAutocompleteSuggestions(inputText, targets, numSuggestions=NU
203203
.sort((a, b) => {return a.localeCompare(b)})
204204

205205
let topMatches = prefixMatches
206-
if (prefixMatches.length < numSuggestions) {
206+
if (prefixMatches.length < NUM_SUGGESTIONS) {
207207
// Get similarly-named genes, as measured by Dice coefficient (`rating`)
208208
const similar = stringSimilarity.findBestMatch(inputText, targets)
209209
const similarMatches =
@@ -220,10 +220,13 @@ export function getAutocompleteSuggestions(inputText, targets, numSuggestions=NU
220220

221221
if (exactMatch) {topMatches.unshift(exactMatch)} // Put any exact match first
222222

223-
const maxPathwaySuggestions = numSuggestions - prefixMatches.length
224-
const pathwaySuggestions = getPathwaySuggestions(inputText, maxPathwaySuggestions)
223+
const maxPathwaySuggestions = NUM_SUGGESTIONS - prefixMatches.length
224+
let pathwaySuggestions = []
225+
if (includePathways) {
226+
pathwaySuggestions = getPathwaySuggestions(inputText, maxPathwaySuggestions)
227+
}
225228

226-
const topGeneMatches = topMatches.slice(0, numSuggestions)
229+
const topGeneMatches = topMatches.slice(0, NUM_SUGGESTIONS)
227230

228231
topMatches = topGeneMatches.concat(pathwaySuggestions)
229232

test/js/explore/study-gene-keyword.test.js

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import React from 'react'
33
import { render, waitFor, screen, fireEvent } from '@testing-library/react'
44
import '@testing-library/jest-dom/extend-expect'
55

6-
import StudyGeneField, { getIsInvalidQuery } from 'components/explore/StudyGeneField'
6+
import StudyGeneField, { getIsInvalidQuery, getIsEligibleForPathwayExplore } from 'components/explore/StudyGeneField'
77
import * as UserProvider from '~/providers/UserProvider'
88
import { interestingNames, interactionCacheCsn1s1 } from './../visualization/pathway.test-data'
99

@@ -67,7 +67,10 @@ describe('Search query display text', () => {
6767
})
6868

6969
const { container } = render(
70-
<StudyGeneField queries={[]} queryFn={() => {}} allGenes={['PTEN']} speciesList={['Homo sapiens']} />
70+
<StudyGeneField
71+
queries={[]} queryFn={() => {}} allGenes={['PTEN']}
72+
speciesList={['Homo sapiens']} selectedAnnotation={{ type: 'group' }}
73+
/>
7174
)
7275

7376
// Find the input field inside react-select
@@ -97,5 +100,26 @@ describe('Search query display text', () => {
97100
const pathwayIsInvalid = getIsInvalidQuery('CSN1S1', ['PTEN'])
98101
expect(pathwayIsInvalid).toBe(false)
99102
})
103+
104+
it('determines if view is eligible for pathway exploration', async () => {
105+
jest
106+
.spyOn(UserProvider, 'getFeatureFlagsWithDefaults')
107+
.mockReturnValue({
108+
show_pathway_expression: true
109+
})
110+
111+
// Confirm mouse is not eligible
112+
const isMouseEligible = getIsEligibleForPathwayExplore(['Mus musculus'], { type: 'group' })
113+
expect(isMouseEligible).toBe(false)
114+
115+
// Confirm numeric annotation is not eligible
116+
const isNumericAnnotationEligible = getIsEligibleForPathwayExplore(['Homo sapiens'], { type: 'numeric' })
117+
expect(isNumericAnnotationEligible).toBe(false)
118+
119+
// Confirm group annotation for human is eligible
120+
const isHumanGroupAnnotationEligible = getIsEligibleForPathwayExplore(['Homo sapiens'], { type: 'group' })
121+
expect(isHumanGroupAnnotationEligible).toBe(true)
122+
})
123+
100124
})
101125

0 commit comments

Comments
 (0)