diff --git a/src/api/mavedb/diseases.ts b/src/api/mavedb/diseases.ts new file mode 100644 index 00000000..09f786ae --- /dev/null +++ b/src/api/mavedb/diseases.ts @@ -0,0 +1,10 @@ +import axios from 'axios' + +import config from '@/config' +import type {DiseaseConcept} from '@/lib/diseases' + +/** Search MONDO disease terms for the calibration disease typeahead. */ +export async function searchDiseases(query: string, signal?: AbortSignal): Promise { + const response = await axios.get(`${config.apiBaseUrl}/diseases/search`, {params: {q: query}, signal}) + return response.data || [] +} diff --git a/src/api/mavedb/index.ts b/src/api/mavedb/index.ts index 3a92e5cf..c27e4335 100644 --- a/src/api/mavedb/index.ts +++ b/src/api/mavedb/index.ts @@ -1,6 +1,7 @@ export * from './access-keys' export * from './calibrations' export * from './collections' +export * from './diseases' export * from './experiments' export * from './genes' export * from './orcid' diff --git a/src/components/calibration/CalibrationControlsField.vue b/src/components/calibration/CalibrationControlsField.vue new file mode 100644 index 00000000..ed5d85ee --- /dev/null +++ b/src/components/calibration/CalibrationControlsField.vue @@ -0,0 +1,198 @@ + + + diff --git a/src/components/calibration/CalibrationControlsPanel.vue b/src/components/calibration/CalibrationControlsPanel.vue new file mode 100644 index 00000000..7177e8f3 --- /dev/null +++ b/src/components/calibration/CalibrationControlsPanel.vue @@ -0,0 +1,279 @@ + + + + + diff --git a/src/components/calibration/CalibrationControlsSummary.vue b/src/components/calibration/CalibrationControlsSummary.vue new file mode 100644 index 00000000..f55d29b1 --- /dev/null +++ b/src/components/calibration/CalibrationControlsSummary.vue @@ -0,0 +1,88 @@ + + + diff --git a/src/components/calibration/CalibrationEditor.vue b/src/components/calibration/CalibrationEditor.vue index 94a384df..ac28fda6 100644 --- a/src/components/calibration/CalibrationEditor.vue +++ b/src/components/calibration/CalibrationEditor.vue @@ -9,6 +9,7 @@ :class-based="classBased" :classes-file-name="draftClassesFile?.name || null" :criterions="criterions" + :disease="draft.disease ?? null" :editable-score-sets="editableScoreSets" :evidence-sources="draft.evidenceSources || []" :evidence-strengths="evidenceStrengths" @@ -35,26 +36,48 @@ @toggle-boundary="onToggleBoundary" @toggle-infinity="onToggleInfinity" @toggle-oddspaths="onToggleOddspaths" - @update:baseline-score="draft.baselineScore = $event; markChanged()" - @update:baseline-score-description="draft.baselineScoreDescription = $event; markChanged()" + @update:baseline-score="((draft.baselineScore = $event), markChanged())" + @update:baseline-score-description="((draft.baselineScoreDescription = $event), markChanged())" @update:class-based="classBased = $event" @update:classification-field="onClassificationFieldUpdate" - @update:evidence-sources="draft.evidenceSources = $event; markChanged()" + @update:evidence-sources="((draft.evidenceSources = $event), markChanged())" @update:evidence-strength="onEvidenceStrengthUpdate" - @update:method-sources="draft.methodSources = $event; markChanged()" - @update:notes="draft.notes = $event; markChanged()" + @update:method-sources="((draft.methodSources = $event), markChanged())" + @update:notes="((draft.notes = $event), markChanged())" @update:range-value="onRangeValueUpdate" - @update:research-use-only="draft.researchUseOnly = $event; markChanged()" + @update:research-use-only="((draft.researchUseOnly = $event), markChanged())" @update:selected-score-set="onScoreSetSelected" - @update:threshold-sources="draft.thresholdSources = $event; markChanged()" - @update:title="draft.title = $event; markChanged()" - /> + @update:threshold-sources="((draft.thresholdSources = $event), markChanged())" + @update:title="((draft.title = $event), markChanged())" + > + + diff --git a/src/components/score-set/ScoreSetHistogram.vue b/src/components/score-set/ScoreSetHistogram.vue index e10a6dd7..14e6af62 100644 --- a/src/components/score-set/ScoreSetHistogram.vue +++ b/src/components/score-set/ScoreSetHistogram.vue @@ -44,21 +44,25 @@ +
+ + +
-
+
Clinical Series Options -
- - +
@@ -70,7 +74,7 @@ style="align-items: center; height: 1.5rem" />
-
+
@@ -96,7 +100,7 @@
-
+
Include variants with classification:
Note: The ClinVar annotations shown above are matched to variants in this score set and may not correspond to the @@ -254,6 +262,14 @@ interface VizOption { clinicalControlLegendNoteEnabled: boolean } +// A selectable clinical-control source: either the score set's own calibration controls or a clinical +// database (e.g. ClinVar). `db` is populated only for database sources and drives the version/query. +interface ControlSourceOption { + label: string + kind: 'calibration' | 'clinvar' + db: ClinicalControlOption | null +} + export default defineComponent({ name: 'ScoreSetHistogram', @@ -324,6 +340,9 @@ export default defineComponent({ config: config, activeViz: 0, + // null = follow the default (calibration controls when the active calibration has them, else ClinVar); + // set explicitly by the source toggle. See effectiveControlSource for how a stale choice is resolved. + controlSource: null as 'calibration' | 'clinvar' | null, showCalibrations: scoreSetHasCalibrations, activeCalibration: {label: 'None', value: null} as { label: string @@ -406,17 +425,106 @@ export default defineComponent({ const calibrationUrn = this.activeCalibration.value?.urn return calibrationUrn != null && this.calibrationClassVariantsLoadingByUrn[calibrationUrn] === true }, - series: function () { - if (!this.refreshedClinicalControls) { - return null + // URN -> clinical status for the active calibration's controls. Small (one entry per control) and + // memoized, so the series classifiers and tooltip do O(1) lookups without an extra pass over variants. + calibrationControlStatusByUrn: function (): Record { + const statusByUrn: Record = {} + for (const control of this.activeCalibration.value?.controls ?? []) { + statusByUrn[control.variantUrn] = control.clinicalStatus + } + return statusByUrn + }, + activeCalibrationHasControls: function (): boolean { + return (this.activeCalibration.value?.controls?.length ?? 0) > 0 + }, + // Which control set the Controls view overlays. Honor an explicit ClinVar choice; honor an explicit + // calibration choice only while controls exist; otherwise default to calibration controls when + // available, falling back to ClinVar. This keeps a stale toggle choice from drawing an empty overlay + // after the active calibration changes, so no reset watcher is needed. + effectiveControlSource: function (): 'calibration' | 'clinvar' { + if (this.controlSource === 'clinvar') { + return 'clinvar' + } + if (this.controlSource === 'calibration' && this.activeCalibrationHasControls) { + return 'calibration' + } + return this.activeCalibrationHasControls ? 'calibration' : 'clinvar' + }, + // Every clinical-control source available for this score set: its own calibration controls (when the + // active calibration has them) plus each clinical database that matched variants (e.g. ClinVar). + controlSourceOptions: function (): ControlSourceOption[] { + const options: ControlSourceOption[] = [] + if (this.activeCalibrationHasControls) { + options.push({label: 'Calibration controls', kind: 'calibration', db: null}) } - + if (this.someVariantsHaveClinicalSignificance) { + for (const db of this.clinicalControlOptions) { + options.push({label: db.dbName, kind: 'clinvar', db}) + } + } + return options + }, + // Two-way binding for the source dropdown. Reads back the option matching the effective source (and the + // active database for ClinVar); writing one flips controlSource and, for a database, its db + version. + selectedControlSource: { + get: function (): ControlSourceOption | null { + if (this.effectiveControlSource === 'calibration') { + return this.controlSourceOptions.find((option) => option.kind === 'calibration') ?? null + } + return ( + this.controlSourceOptions.find( + (option) => option.kind === 'clinvar' && option.db?.dbName === this.controlDb?.dbName + ) ?? + this.controlSourceOptions.find((option) => option.kind === 'clinvar') ?? + null + ) + }, + set: function (option: ControlSourceOption | null): void { + if (!option) { + return + } + if (option.kind === 'calibration') { + this.controlSource = 'calibration' + return + } + this.controlSource = 'clinvar' + if (option.db) { + this.controlDb = option.db + // Reset to the database's default version when the current one does not belong to the new database. + if (!option.db.availableVersions.includes(this.controlVersion ?? '')) { + this.controlVersion = + option.db.availableVersions.find((version) => version === DEFAULT_CLINICAL_CONTROL_VERSION) ?? + option.db.availableVersions[0] ?? + null + } + } + } + }, + // The source dropdown is only meaningful on the control-backed views and when more than one source exists. + showControlSourceSelector: function (): boolean { + const view = this.vizOptions[this.activeViz]?.view + return (view === 'clinical' || view === 'custom') && this.controlSourceOptions.length > 1 + }, + // The version dropdown only applies to a ClinVar-style database source that publishes multiple versions. + showClinicalControlVersionOption: function (): boolean { + return this.effectiveControlSource === 'clinvar' && (this.controlDb?.availableVersions.length ?? 0) > 1 + }, + series: function () { this.assureActiveVizIsAvailable() if (!this.vizOptions[this.activeViz]) { return null } + // The calibration-controls overlay is driven by the score set's own calibration and does not depend + // on the background ClinVar control fetch; every ClinVar-sourced view still waits for it. + const view = this.vizOptions[this.activeViz].view + const usingCalibrationControls = + (view === 'clinical' || view === 'custom') && this.effectiveControlSource === 'calibration' + if (!this.refreshedClinicalControls && !usingCalibrationControls) { + return null + } + switch (this.vizOptions[this.activeViz].view) { case 'calibration-classes': { if (!this.selectedCalibrationIsClassBased) { @@ -439,7 +547,22 @@ export default defineComponent({ } })) } - case 'clinical': + case 'clinical': { + // Calibration controls: the score set's own pathogenic/benign ground truth, matched by URN. + if (this.effectiveControlSource === 'calibration') { + const statusByUrn = this.calibrationControlStatusByUrn + return [ + { + classifier: (d: HistogramDatum) => !!d.accession && statusByUrn[d.accession] === 'pathogenic', + options: {color: '#e41a1c', title: 'Pathogenic'} + }, + { + classifier: (d: HistogramDatum) => !!d.accession && statusByUrn[d.accession] === 'benign', + options: {color: '#377eb8', title: 'Benign'} + } + ] + } + return [ { classifier: (d: HistogramDatum) => @@ -464,6 +587,7 @@ export default defineComponent({ } } ] + } case 'effect': return [ @@ -509,68 +633,90 @@ export default defineComponent({ ] case 'custom': { - const series = [ - { - classifier: (d: HistogramDatum) => - _.intersection( - PATHOGENIC_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS, - this.selectedClinicalSignificanceClassifications - ).includes(d.control?.[DEFAULT_CLNSIG_FIELD]) && - CLINVAR_REVIEW_STATUS_STARS[d.control?.[DEFAULT_CLNREVSTAT_FIELD]] >= this.minStarRating && - this.filterControlVariantByEffect(d), - options: { - color: '#e41a1c', - title: 'Pathogenic/Likely Pathogenic' + const series: {classifier: (d: HistogramDatum) => boolean; options: {color: string; title: string}}[] = [] + + if (this.effectiveControlSource === 'calibration') { + // Calibration controls carry only pathogenic/benign ground truth — no review stars or ClinVar + // sub-classes — so the significance filters do not apply, but the protein-effect filter still does. + const statusByUrn = this.calibrationControlStatusByUrn + series.push( + { + classifier: (d: HistogramDatum) => + !!d.accession && statusByUrn[d.accession] === 'pathogenic' && this.filterControlVariantByEffect(d), + options: {color: '#e41a1c', title: 'Pathogenic'} + }, + { + classifier: (d: HistogramDatum) => + !!d.accession && statusByUrn[d.accession] === 'benign' && this.filterControlVariantByEffect(d), + options: {color: '#377eb8', title: 'Benign'} } - }, - { - classifier: (d: HistogramDatum) => - _.intersection( - BENIGN_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS, - this.selectedClinicalSignificanceClassifications - ).includes(d.control?.[DEFAULT_CLNSIG_FIELD]) && - CLINVAR_REVIEW_STATUS_STARS[d.control?.[DEFAULT_CLNREVSTAT_FIELD]] >= this.minStarRating && - this.filterControlVariantByEffect(d), - options: { - color: '#377eb8', - title: 'Benign/Likely Benign' + ) + } else { + series.push( + { + classifier: (d: HistogramDatum) => + _.intersection( + PATHOGENIC_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS, + this.selectedClinicalSignificanceClassifications + ).includes(d.control?.[DEFAULT_CLNSIG_FIELD]) && + CLINVAR_REVIEW_STATUS_STARS[d.control?.[DEFAULT_CLNREVSTAT_FIELD]] >= this.minStarRating && + this.filterControlVariantByEffect(d), + options: { + color: '#e41a1c', + title: 'Pathogenic/Likely Pathogenic' + } + }, + { + classifier: (d: HistogramDatum) => + _.intersection( + BENIGN_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS, + this.selectedClinicalSignificanceClassifications + ).includes(d.control?.[DEFAULT_CLNSIG_FIELD]) && + CLINVAR_REVIEW_STATUS_STARS[d.control?.[DEFAULT_CLNREVSTAT_FIELD]] >= this.minStarRating && + this.filterControlVariantByEffect(d), + options: { + color: '#377eb8', + title: 'Benign/Likely Benign' + } } - } - ] + ) - if (this.selectedClinicalSignificanceClassifications.includes('Uncertain significance')) { - series.push({ - classifier: (d: Variant) => - d.control?.[DEFAULT_CLNSIG_FIELD] == 'Uncertain significance' && - (CLINVAR_REVIEW_STATUS_STARS[d.control?.[DEFAULT_CLNREVSTAT_FIELD]] ?? -1) >= this.minStarRating && - this.filterControlVariantByEffect(d), - options: { - color: '#999999', - title: 'Uncertain significance' - } - }) - } + if (this.selectedClinicalSignificanceClassifications.includes('Uncertain significance')) { + series.push({ + classifier: (d: HistogramDatum) => + d.control?.[DEFAULT_CLNSIG_FIELD] == 'Uncertain significance' && + (CLINVAR_REVIEW_STATUS_STARS[d.control?.[DEFAULT_CLNREVSTAT_FIELD]] ?? -1) >= this.minStarRating && + this.filterControlVariantByEffect(d), + options: { + color: '#999999', + title: 'Uncertain significance' + } + }) + } - // Account for both possible conflicting classifications. - if ( - this.selectedClinicalSignificanceClassifications.includes('Conflicting classifications of pathogenicity') || - this.selectedClinicalSignificanceClassifications.includes('Conflicting interpretations of pathogenicity') - ) { - series.push({ - classifier: (d: HistogramDatum) => - _.intersection( - CONFLICTING_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS, - this.selectedClinicalSignificanceClassifications - ).includes(d.control?.[DEFAULT_CLNSIG_FIELD]) && - CLINVAR_REVIEW_STATUS_STARS[d.control?.[DEFAULT_CLNREVSTAT_FIELD]] >= this.minStarRating && - this.filterControlVariantByEffect(d), - options: { - color: '#984ea3', - title: conflictingClinicalSignificanceSeriesLabelForVersion( - this.controlVersion ? this.controlVersion : DEFAULT_CLINICAL_CONTROL_VERSION - ) - } - }) + // Account for both possible conflicting classifications. + if ( + this.selectedClinicalSignificanceClassifications.includes( + 'Conflicting classifications of pathogenicity' + ) || + this.selectedClinicalSignificanceClassifications.includes('Conflicting interpretations of pathogenicity') + ) { + series.push({ + classifier: (d: HistogramDatum) => + _.intersection( + CONFLICTING_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS, + this.selectedClinicalSignificanceClassifications + ).includes(d.control?.[DEFAULT_CLNSIG_FIELD]) && + CLINVAR_REVIEW_STATUS_STARS[d.control?.[DEFAULT_CLNREVSTAT_FIELD]] >= this.minStarRating && + this.filterControlVariantByEffect(d), + options: { + color: '#984ea3', + title: conflictingClinicalSignificanceSeriesLabelForVersion( + this.controlVersion ? this.controlVersion : DEFAULT_CLINICAL_CONTROL_VERSION + ) + } + }) + } } if (this.proteinEffectOptionsAvailable && this.selectedVariantTypeFilters.includes('Missense')) { @@ -636,8 +782,8 @@ export default defineComponent({ {label: 'Overall Distribution', view: 'distribution', clinicalControlLegendNoteEnabled: false} ] - if (this.someVariantsHaveClinicalSignificance) { - options.push({label: 'Clinical View', view: 'clinical', clinicalControlLegendNoteEnabled: true}) + if (this.someVariantsHaveClinicalSignificance || this.activeCalibrationHasControls) { + options.push({label: 'Controls', view: 'clinical', clinicalControlLegendNoteEnabled: true}) } if (this.selectedCalibrationIsClassBased) { @@ -683,14 +829,6 @@ export default defineComponent({ } }, - showClinicalControlOptions: function () { - const hasMultipleDbs = this.clinicalControlOptions.length > 1 - const hasSingleDbWithMultipleVersions = - this.clinicalControlOptions.length == 1 && this.clinicalControlOptions[0].availableVersions.length > 1 - - return hasMultipleDbs || hasSingleDbWithMultipleVersions - }, - activeCalibrationOptions: function () { if (!this.scoreCalibrations) return [] @@ -816,6 +954,19 @@ export default defineComponent({ parts.push(variantLabel) } + // Calibration control membership in the active calibration — a fact about the variant, shown + // regardless of which control source the chart is currently overlaying. + const calibrationControlStatus = variant.accession + ? this.calibrationControlStatusByUrn[variant.accession] + : undefined + if (calibrationControlStatus) { + const controlLabel = calibrationControlStatus === 'pathogenic' ? 'Pathogenic' : 'Benign' + const controlColor = calibrationControlStatus === 'pathogenic' ? '#e41a1c' : '#377eb8' + parts.push( + `Calibration control: ${controlLabel}` + ) + } + // Line 2: Variant description const variantDescriptionParts = [] if (seriesContainingVariant.length == 0) { @@ -1235,8 +1386,14 @@ export default defineComponent({ .seriesClassifier(seriesClassifier) .title('Distribution of Functional Scores') .legendNote( - this.vizOptions[this.activeViz]?.clinicalControlLegendNoteEnabled && this.refreshedClinicalControls - ? `${this.controlDb?.dbName} data from version ${this.controlVersion}` + // The version note describes a ClinVar-style database source only; calibration controls have no such + // provenance, and an unloaded database would otherwise render "undefined data from version null". + this.vizOptions[this.activeViz]?.clinicalControlLegendNoteEnabled && + this.refreshedClinicalControls && + this.effectiveControlSource === 'clinvar' && + this.controlDb?.dbName && + this.controlVersion + ? `${this.controlDb.dbName} data from version ${this.controlVersion}` : null ) .shaders(this.histogramShaders) @@ -1583,6 +1740,20 @@ export default defineComponent({ color: #6c757d; } +.mavedb-histogram-source-select { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 0.75rem; +} + +.mavedb-histogram-source-select-label { + font-size: 13px; + font-weight: 500; + color: #6c757d; + white-space: nowrap; +} + .mavedb-histogram-controls { display: flex; align-items: flex-end; diff --git a/src/components/screens/ScoreSetCalibrationsView.vue b/src/components/screens/ScoreSetCalibrationsView.vue index f3f302c5..c6849f32 100644 --- a/src/components/screens/ScoreSetCalibrationsView.vue +++ b/src/components/screens/ScoreSetCalibrationsView.vue @@ -54,7 +54,11 @@ - + @@ -175,14 +177,7 @@ @@ -246,6 +241,7 @@ import {describeRequestError} from '@/lib/errors' import {getScoreSetShortName} from '@/lib/score-sets' import {useConfirm} from 'primevue/useconfirm' import CalibrationEditor from '@/components/calibration/CalibrationEditor.vue' +import {diseaseDisplayLabel} from '@/lib/diseases' import MvEmailPrompt from '@/components/common/MvEmailPrompt.vue' import {ref, toRef} from 'vue' import PrimeDialog from 'primevue/dialog' @@ -255,6 +251,7 @@ import MvRowActionMenu, {type RowAction} from '@/components/common/MvRowActionMe import {components} from '@/schema/openapi' type ScoreSet = components['schemas']['ScoreSet'] +type ScoreCalibration = components['schemas']['ScoreCalibration'] const CALIBRATION_ACTIONS = ['update', 'delete', 'publish', 'change_rank'] as const type CalibrationAuthorizations = Record<(typeof CALIBRATION_ACTIONS)[number], boolean> @@ -356,7 +353,12 @@ export default { } }, methods: { - calibrationSortKey(cal: components['schemas']['ScoreCalibration']): number { + /** The calibration's disease label, generic "disease or disorder" default included; null only when no concept is present. */ + diseaseLabel(cal: ScoreCalibration): string | null { + return diseaseDisplayLabel(cal.disease) + }, + + calibrationSortKey(cal: ScoreCalibration): number { if (cal.primary) return 0 if (cal.private) return 4 if (cal.researchUseOnly) return 3 @@ -364,7 +366,7 @@ export default { return 2 // community }, - calibrationActions(data: components['schemas']['ScoreCalibration']): RowAction[] { + calibrationActions(data: ScoreCalibration): RowAction[] { const actions: RowAction[] = [ { label: 'View in score set', diff --git a/src/components/screens/ScoreSetCreator.vue b/src/components/screens/ScoreSetCreator.vue index 752ee7c2..606eb543 100644 --- a/src/components/screens/ScoreSetCreator.vue +++ b/src/components/screens/ScoreSetCreator.vue @@ -312,6 +312,7 @@ diff --git a/src/components/screens/ScoreSetEditor.vue b/src/components/screens/ScoreSetEditor.vue index efee06f4..4fa8d6da 100644 --- a/src/components/screens/ScoreSetEditor.vue +++ b/src/components/screens/ScoreSetEditor.vue @@ -179,8 +179,8 @@
- -
+ +

Variant scores

diff --git a/src/components/variant/VariantInfoSection.vue b/src/components/variant/VariantInfoSection.vue index 65ce374e..7001dfac 100644 --- a/src/components/variant/VariantInfoSection.vue +++ b/src/components/variant/VariantInfoSection.vue @@ -22,6 +22,10 @@ + + + used to derive this calibration + @@ -39,8 +43,10 @@