Skip to content

Commit db366b2

Browse files
authored
Merge pull request #2280 from broadinstitute/development
Release 1.99.0
2 parents 7ed1313 + 6a1fe17 commit db366b2

19 files changed

+509
-12
lines changed

app/controllers/api/v1/visualization/expression_controller.rb

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ class ExpressionController < ApiBaseController
4545
key :description, 'Type of plot data requested'
4646
key :required, true
4747
key :type, :string
48-
key :enum, %w(violin heatmap morpheus)
48+
key :enum, %w(violin heatmap morpheus dotplot)
4949
end
5050
parameter do
5151
key :name, :cluster
@@ -117,6 +117,8 @@ def show
117117
render_heatmap
118118
when 'morpheus'
119119
render_morpheus_json
120+
when 'dotplot'
121+
render_dotplot
120122
else
121123
render json: { error: "Unknown expression data type: #{data_type}" }, status: :bad_request
122124
end
@@ -174,12 +176,23 @@ def render_morpheus_json
174176
render json: expression_data, status: :ok
175177
end
176178

179+
def render_dotplot
180+
if @cluster.nil?
181+
render json: { error: 'Requested cluster not found' }, status: :not_found and return
182+
end
183+
184+
expression_data = ExpressionVizService.load_precomputed_dot_plot_data(
185+
@study, @cluster, annotation: @annotation, genes: @genes
186+
)
187+
render json: expression_data, status: :ok
188+
end
189+
177190
private
178191

179192
# enforce a limit on number of genes allowed for visualization requests
180193
# see StudySearchService::MAX_GENE_SEARCH
181194
def check_gene_limit
182-
return true if params[:genes].blank?
195+
return true if params[:genes].blank? || params[:data_type] == 'dotplot'
183196

184197
# render 422 if more than MAX_GENE_SEARCH as request fails internal validation
185198
num_genes = params[:genes].split(',').size
@@ -194,7 +207,11 @@ def set_cluster
194207
end
195208

196209
def set_genes
197-
@genes = RequestUtils.get_genes_from_param(@study, params[:genes])
210+
if params[:data_type] == 'dotplot'
211+
@genes = params[:genes].split(',').map(&:strip).reject(&:empty?)
212+
else
213+
@genes = RequestUtils.get_genes_from_param(@study, params[:genes])
214+
end
198215
end
199216

200217
def set_selected_annotation

app/javascript/components/search/results/ResultsPanel.jsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react'
22
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
3-
import { faInfoCircle } from '@fortawesome/free-solid-svg-icons'
3+
import { faInfoCircle, faExclamationCircle } from '@fortawesome/free-solid-svg-icons'
44

55
import StudyResults from './StudyResults'
66
import StudySearchResult from './StudySearchResult'
@@ -18,6 +18,21 @@ import LoadingSpinner from '~/lib/LoadingSpinner'
1818
*/
1919
const ResultsPanel = ({ studySearchState, studyComponent, noResultsDisplay, bookmarks }) => {
2020
const results = studySearchState.results
21+
const hcaMessage = <div className='flexbox alert alert-warning'>
22+
<div className="">
23+
<FontAwesomeIcon icon={faExclamationCircle} className="fa-lg fa-fw icon-left"/>
24+
</div>
25+
<p>Broadening your search to include the <a
26+
className='hca-link'
27+
onClick={() => studySearchState.updateSearch({ external: 'hca' })}
28+
data-analytics-event='search-hca-empty-results'>
29+
Human Cell Atlas Data Portal
30+
</a> may return more results.</p>
31+
</div>
32+
33+
const emptyResultMessage = <div>
34+
No results found. { studySearchState?.params?.external === "" ? hcaMessage : null }
35+
</div>
2136

2237
let panelContent
2338
if (studySearchState.isError) {
@@ -47,7 +62,7 @@ const ResultsPanel = ({ studySearchState, studyComponent, noResultsDisplay, book
4762
</>
4863
)
4964
} else {
50-
noResultsDisplay = noResultsDisplay ? noResultsDisplay : <div> No results found. </div>
65+
noResultsDisplay = noResultsDisplay ? noResultsDisplay : emptyResultMessage
5166
panelContent = (
5267
<>
5368
<SearchQueryDisplay terms={results.termList} facets={results.facets} />

app/javascript/styles/_colors.scss

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
$action-color: #3D5A87;
55

66
/* non-fatal errors -- color palette borrowed from Terra */
7+
$warning-color: #8a6d3b;
8+
$warning-bg: #fcf8e3;
79
$warning-background-color: #feeed8;
810
$warning-icon-color: #C45500;
911

app/javascript/styles/_global.scss

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,20 @@ h6,
518518
font-size: 14px;
519519
}
520520

521+
.alert.alert-warning {
522+
color: $warning-color;
523+
border: none;
524+
border-left: 6px solid $warning-icon-color;
525+
border-radius: 0px;
526+
background: $warning-bg;
527+
a {
528+
text-decoration: underline;
529+
font-weight: bold;
530+
}
531+
margin-top: 1em;
532+
font-size: 14px;
533+
}
534+
521535
/* Slightly override container settings, e.g. for cluster validation errors */
522536
.alert.alert-danger ul {
523537
color: $danger-color;

app/javascript/styles/_resultsPanel.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,8 @@
150150
min-width: 120px;
151151
height: 20px;
152152
}
153+
154+
.hca-link {
155+
font-weight: bold;
156+
cursor: pointer;
157+
}

app/lib/dot_plot_service.rb

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# frozen_string_literal: true
2+
3+
# service that handles preprocessing expression/annotation data to speed up dot plot rendering
4+
class DotPlotService
5+
# main handler for launching ingest job to process expression data
6+
#
7+
# * *params*
8+
# - +study+ (Study) => the study that owns the data
9+
# - +cluster_group+ (ClusterGroup) => the cluster to source cell names from
10+
# - +annotation_file+ (StudyFile) => the StudyFile containing annotation data
11+
# - +expression_file+ (StudyFile) => the StudyFile to source data from
12+
#
13+
# * *yields*
14+
# - (IngestJob) => the job that will be run to process the data
15+
def self.run_preprocess_expression_job(study, cluster_group, annotation_file, expression_file)
16+
study_eligible?(study) # method stub, waiting for scp-ingest-pipeline implementation
17+
end
18+
19+
# determine study eligibility - can only have one processed matrix and be able to visualize clusters
20+
#
21+
# * *params*
22+
# - +study+ (Study) the study that owns the data
23+
# * *returns*
24+
# - (Boolean) true if the study is eligible for dot plot visualization
25+
def self.study_eligible?(study)
26+
processed_matrices = study_processed_matrices(study)
27+
study.can_visualize_clusters? && study.has_expression_data? && processed_matrices.size == 1
28+
end
29+
30+
# check if the given study/cluster has already been preprocessed
31+
# * *params*
32+
# - +study+ (Study) the study that owns the data
33+
# - +cluster_group+ (ClusterGroup) the cluster to check for processed data
34+
#
35+
# * *returns*
36+
# - (Boolean) true if the study/cluster has already been processed
37+
def self.cluster_processed?(study, cluster_group)
38+
DotPlotGene.where(study:, cluster_group:).exists?
39+
end
40+
41+
# get processed expression matrices for a study
42+
#
43+
# * *params*
44+
# - +study+ (Study) the study to get matrices for
45+
#
46+
# * *returns*
47+
# - (Array<StudyFile>) an array of processed expression matrices for the study
48+
def self.study_processed_matrices(study)
49+
study.expression_matrices.select do |matrix|
50+
matrix.is_viz_anndata? || !matrix.is_raw_counts_file?
51+
end
52+
end
53+
54+
# seeding method for testing purposes, will be removed once pipeline is in place
55+
# data is random and not representative of actual expression data
56+
def self.seed_dot_plot_genes(study)
57+
return false unless study_eligible?(study)
58+
59+
DotPlotGene.where(study_id: study.id).delete_all
60+
puts "Seeding dot plot genes for #{study.accession}"
61+
expression_matrix = study.expression_matrices.first
62+
print 'assembling genes and annotations...'
63+
genes = Gene.where(study:, study_file: expression_matrix).pluck(:name)
64+
annotations = AnnotationVizService.available_metadata_annotations(
65+
study, annotation_type: 'group'
66+
).reject { |a| a[:scope] == 'invalid' }
67+
puts " done. Found #{genes.size} genes and #{annotations.size} study-wide annotations."
68+
study.cluster_groups.each do |cluster_group|
69+
next if cluster_processed?(study, cluster_group)
70+
71+
cluster_annotations = ClusterVizService.available_annotations_by_cluster(
72+
cluster_group, 'group'
73+
).reject { |a| a[:scope] == 'invalid' }
74+
all_annotations = annotations + cluster_annotations
75+
puts "Processing #{cluster_group.name} with #{all_annotations.size} annotations."
76+
documents = []
77+
genes.each do |gene|
78+
exp_scores = all_annotations.map do |annotation|
79+
{
80+
"#{annotation[:name]}--#{annotation[:type]}--#{annotation[:scope]}" => annotation[:values].map do |value|
81+
{ value => [rand.round(3), rand.round(3)] }
82+
end.reduce({}, :merge)
83+
}
84+
end.reduce({}, :merge)
85+
documents << DotPlotGene.new(
86+
study:, study_file: expression_matrix, cluster_group:, gene_symbol: gene, searchable_gene: gene.downcase,
87+
exp_scores:
88+
).attributes
89+
if documents.size == 1000
90+
DotPlotGene.collection.insert_many(documents)
91+
count = DotPlotGene.where(study_id: study.id, cluster_group_id: cluster_group.id).size
92+
puts "Inserted #{count}/#{genes.size} DotPlotGenes for #{cluster_group.name}."
93+
documents.clear
94+
end
95+
end
96+
DotPlotGene.collection.insert_many(documents)
97+
count = DotPlotGene.where(study_id: study.id, cluster_group_id: cluster_group.id).size
98+
puts "Inserted #{count}/#{genes.size} DotPlotGenes for #{cluster_group.name}."
99+
puts "Finished processing #{cluster_group.name}"
100+
end
101+
puts "Seeding complete for #{study.accession}, #{DotPlotGene.where(study_id: study.id).size} DotPlotGenes created."
102+
true
103+
rescue StandardError => e
104+
puts "Error seeding DotPlotGenes in #{study.accession}: #{e.message}"
105+
false
106+
end
107+
end

app/lib/expression_viz_service.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,4 +472,16 @@ def self.get_column_metadata(cells, annotation_name, annotations)
472472
]
473473
}
474474
end
475+
476+
# load precomputed dot plot data for a given study and cluster and gene set
477+
def self.load_precomputed_dot_plot_data(study, cluster_group, annotation: {}, genes: [])
478+
data = { annotation_name: annotation[:name], values: annotation[:values], genes: {} }
479+
dot_plot_genes = DotPlotGene.where(study:, cluster_group:, :searchable_gene.in => genes.map(&:downcase))
480+
dot_plot_genes.map do |gene|
481+
data[:genes][gene.gene_symbol] = gene.scores_by_annotation(
482+
annotation[:name], annotation[:scope], annotation[:values]
483+
)
484+
end
485+
data
486+
end
475487
end

app/models/delete_queue_job.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,10 @@ def perform
3535
# now remove all child objects first to free them up to be re-used.
3636
case file_type
3737
when 'Cluster'
38+
cluster_group = ClusterGroup.find_by(study:, study_file_id: object.id)
3839
delete_differential_expression_results(study:, study_file: object)
3940
delete_parsed_data(object.id, study.id, ClusterGroup, DataArray)
41+
delete_dot_plot_data(study.id, query: { cluster_group_id: cluster_group&.id })
4042
delete_user_annotations(study:, study_file: object)
4143
reset_default_cluster(study:)
4244
reset_default_annotation(study:)
@@ -45,6 +47,7 @@ def perform
4547
remove_file_from_bundle
4648
when 'Expression Matrix'
4749
delete_parsed_data(object.id, study.id, Gene, DataArray)
50+
delete_dot_plot_data(study.id, query: { study_file_id: object.id })
4851
delete_differential_expression_results(study:, study_file: object)
4952
study.set_gene_count
5053
when 'MM Coordinate Matrix'
@@ -73,6 +76,7 @@ def perform
7376
end
7477
delete_differential_expression_results(study:, study_file: object)
7578
delete_parsed_data(object.id, study.id, CellMetadatum, DataArray)
79+
delete_dot_plot_data(study.id)
7680
delete_cell_index_arrays(study)
7781
study.update(cell_count: 0)
7882
reset_default_annotation(study:)
@@ -81,6 +85,7 @@ def perform
8185
# delete user annotations first as we lose associations later
8286
delete_user_annotations(study:, study_file: object)
8387
delete_parsed_data(object.id, study.id, ClusterGroup, CellMetadatum, Gene, DataArray)
88+
delete_dot_plot_data(study.id)
8489
delete_fragment_files(study:, study_file: object)
8590
delete_differential_expression_results(study:, study_file: object)
8691
# reset default options/counts
@@ -338,4 +343,12 @@ def delete_parsed_anndata_entries(study_file_id, study_id, fragment)
338343
data_arrays.delete_all
339344
end
340345
end
346+
347+
# delete preprocessed dot plot data for a study with a specific query
348+
# if a user deletes processed expression/metadata file, all data is cleaned up
349+
# if a user delete a cluster file, only matching entries are removed
350+
def delete_dot_plot_data(study_id, query: nil)
351+
dot_query = query.blank? ? { study_id: } : { study_id:, **query }
352+
DotPlotGene.where(dot_query).delete_all
353+
end
341354
end

app/models/dot_plot_gene.rb

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
class DotPlotGene
2+
include Mongoid::Document
3+
include Mongoid::Timestamps
4+
5+
belongs_to :study
6+
belongs_to :study_file # expression matrix, not clustering file - needed for data cleanup
7+
belongs_to :cluster_group
8+
9+
field :gene_symbol, type: String
10+
field :searchable_gene, type: String
11+
field :exp_scores, type: Hash, default: {}
12+
13+
validates :study, :study_file, :cluster_group, presence: true
14+
validates :gene_symbol, uniqueness: { scope: %i[study study_file cluster_group] }, presence: true
15+
16+
before_validation :set_searchable_gene, on: :create
17+
index({ study_id: 1, study_file_id: 1, cluster_group_id: 1 }, { unique: false, background: true })
18+
index({ study_id: 1, cluster_group_id: 1, searchable_gene: 1 },
19+
{ unique: true, background: true })
20+
21+
def scores_by_annotation(annotation_name, annotation_scope, values)
22+
identifier = "#{annotation_name}--group--#{annotation_scope}"
23+
scores = exp_scores[identifier] || {}
24+
values.map { |val| scores[val] || [0.0, 0.0] }
25+
end
26+
27+
private
28+
29+
def set_searchable_gene
30+
self.searchable_gene = gene_symbol.downcase
31+
end
32+
end

app/models/feature_announcement.rb

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,19 @@ def record_latest_event
7070
private
7171

7272
def set_slug
73-
if new_record? || title_changed? && !published
74-
today = created_at.nil? ? Time.zone.today : created_at
75-
self.slug = "#{today.strftime('%F')}-#{title.downcase.gsub(/[^a-zA-Z0-9]+/, '-').chomp('-')}"
73+
if published
74+
published_date = history[:published] || Date.today
75+
# determine if we have an existing title slug we need to preserve
76+
# this prevents published links from breaking when the title changes
77+
# NOTE: unpublishing a feature will always break any published links
78+
if new_record? || slug&.starts_with?('unpublished-')
79+
title_entry = title.downcase.gsub(/[^a-zA-Z0-9]+/, '-')
80+
else
81+
title_entry = slug.split('-')[3..].join('-')
82+
end
83+
self.slug = "#{published_date.strftime('%F')}-#{title_entry.chomp('-')}"
84+
else
85+
self.slug = "unpublished-#{id}"
7686
end
7787
end
7888
end

0 commit comments

Comments
 (0)