Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions src/components/CalibrationTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,7 @@
}
]"
>
<span>
{{ range.variants ? range.variants.length : 0 }}
</span>
<span> {{ range.variantCount ?? 0 }} variants </span>
</div>
</template>

Expand Down
153 changes: 135 additions & 18 deletions src/components/ScoreSetHistogram.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@
<ProgressSpinner style="height: 24px; width: 24px" />
Loading clinical control options in the background. Additional histogram views will be available once loaded.
</div>
<div v-if="isCalibrationClassViewActive && isLoadingActiveCalibrationVariants" style="font-size: small">
<ProgressSpinner style="height: 24px; width: 24px" />
Loading calibration class variants.
</div>
<div v-if="showControls" class="mavedb-histogram-custom-controls">
<fieldset class="mavedb-histogram-controls-panel">
<legend>Clinical Series Options</legend>
Expand Down Expand Up @@ -176,7 +180,8 @@ import makeHistogram, {
HistogramShader,
CATEGORICAL_SERIES_COLORS
} from '@/lib/histogram'
import {prepareCalibrationsForHistogram, shaderOverlapsBin} from '@/lib/calibrations'
import {fetchScoreCalibrationVariants, prepareCalibrationsForHistogram, shaderOverlapsBin} from '@/lib/calibrations'
import type {FunctionalClassificationVariant} from '@/lib/calibrations'
import {variantNotNullOrNA} from '@/lib/mave-hgvs'
import {
DEFAULT_VARIANT_EFFECT_TYPES,
Expand Down Expand Up @@ -313,6 +318,8 @@ export default defineComponent({
customSelectedControlVariantTypeFilters: DEFAULT_VARIANT_EFFECT_TYPES.concat(
this.hideStartAndStopLossByDefault ? [] : ['Start/Stop Loss']
),
calibrationClassVariantsByUrn: {} as Record<string, Record<number, FunctionalClassificationVariant[]>>,
calibrationClassVariantsLoadingByUrn: {} as Record<string, boolean>,
histogram: null as Histogram | null
}
},
Expand All @@ -332,13 +339,23 @@ export default defineComponent({
return null
}

const calibrationUrn = this.activeCalibration.value?.urn
if (!calibrationUrn) {
return null
}

const variantsByClassificationId = this.calibrationClassVariantsByUrn[calibrationUrn]
if (!variantsByClassificationId) {
return null
}

const classMap: Record<string, string> = {}
for (const fc of this.activeCalibration.value!.functionalClassifications!) {
if (fc.class == null) {
if (fc.class == null || fc.id == null) {
continue
}

for (const v of fc.variants || []) {
for (const v of variantsByClassificationId[fc.id] || []) {
if (!v.urn) {
continue
}
Expand All @@ -348,6 +365,13 @@ export default defineComponent({
}
return classMap
},
isCalibrationClassViewActive: function () {
return this.vizOptions[this.activeViz]?.view === 'calibration-classes'
},
isLoadingActiveCalibrationVariants: function () {
const calibrationUrn = this.activeCalibration.value?.urn
return calibrationUrn != null && this.calibrationClassVariantsLoadingByUrn[calibrationUrn] === true
},
series: function () {
if (!this.refreshedClinicalControls) {
return null
Expand All @@ -360,20 +384,27 @@ export default defineComponent({
}

switch (this.vizOptions[this.activeViz].view) {
case 'calibration-classes':
return this.selectedCalibrationIsClassBased
? this.activeCalibration.value?.functionalClassifications?.map((fc, i) => ({
classifier: (d: HistogramDatum) => {
if (!d.accession) return false
return this.selectedCalibrationClassMap?.[d.accession] === fc.class
},
options: {
// not robust to many classes (>12), colors will collide.
color: CATEGORICAL_SERIES_COLORS[i % CATEGORICAL_SERIES_COLORS.length],
title: fc.label || 'Unlabeled'
}
}))
: null
case 'calibration-classes': {
if (!this.selectedCalibrationIsClassBased) {
return null
}

const selectedCalibrationClassMap = this.selectedCalibrationClassMap
if (!selectedCalibrationClassMap) {
return null
}

return this.activeCalibration.value?.functionalClassifications?.map((fc, i) => ({
classifier: (d: HistogramDatum) => {
if (!d.accession) return false
return selectedCalibrationClassMap[d.accession] === fc.class
},
options: {
color: CATEGORICAL_SERIES_COLORS[i % CATEGORICAL_SERIES_COLORS.length],
title: fc.label || 'Unlabeled'
}
}))
}
case 'clinical':
return [
{
Expand Down Expand Up @@ -878,6 +909,9 @@ export default defineComponent({
watch: {
scoreSet: {
handler: async function () {
this.calibrationClassVariantsByUrn = {}
this.calibrationClassVariantsLoadingByUrn = {}

if (this.config.CLINICAL_FEATURES_ENABLED) {
await this.loadClinicalControlOptions()
}
Expand All @@ -896,11 +930,30 @@ export default defineComponent({
}
},
activeCalibration: {
handler: function () {
handler: async function () {
await this.conditionallyLoadCalibrationClassVariants()
this.renderOrRefreshHistogram()
this.$emit('calibrationChanged', this.activeCalibration.value?.urn ?? null)
}
},
activeViz: {
handler: async function () {
await this.conditionallyLoadCalibrationClassVariants()
}
},
calibrationClassVariantsByUrn: {
handler: function (newValue, oldValue) {
const activeUrn = this.activeCalibration?.value?.urn
if (activeUrn) {
const newVariants = newValue && newValue[activeUrn]
const oldVariants = oldValue && oldValue[activeUrn]
if (newVariants === oldVariants) {
return
}
}
this.renderOrRefreshHistogram()
}
},
showCalibrations: {
handler: function () {
this.renderOrRefreshHistogram()
Expand Down Expand Up @@ -1035,6 +1088,7 @@ export default defineComponent({
this.renderOrRefreshHistogram()
this.$emit('exportChart', this.exportChart)
this.activeCalibration = this.chooseDefaultCalibration()
await this.conditionallyLoadCalibrationClassVariants()
},

beforeUnmount: function () {
Expand Down Expand Up @@ -1151,6 +1205,69 @@ export default defineComponent({
}
this.$emit('selection-changed', payload)
},
conditionallyLoadCalibrationClassVariants: async function () {
if (!this.isCalibrationClassViewActive || !this.selectedCalibrationIsClassBased) {
return
}

await this.loadCalibrationClassVariants(this.activeCalibration.value?.urn ?? null)
},
loadCalibrationClassVariants: async function (calibrationUrn: string | null) {
if (!calibrationUrn) {
return
}

if (
this.calibrationClassVariantsByUrn[calibrationUrn] ||
this.calibrationClassVariantsLoadingByUrn[calibrationUrn]
) {
return
}

this.calibrationClassVariantsLoadingByUrn = {
...this.calibrationClassVariantsLoadingByUrn,
[calibrationUrn]: true
}

try {
// TODO#622calibration-classes-performance: If very large calibrations become slow, optimize by
// precomputing and caching an accession->class map at fetch time and adding LRU-style cache
// eviction for calibrationClassVariantsByUrn to cap memory usage across many calibrations.
const response = await fetchScoreCalibrationVariants(calibrationUrn)
const variantsByClassificationId: Record<number, FunctionalClassificationVariant[]> = {}

for (const variantSet of response || []) {
variantsByClassificationId[variantSet.functionalClassificationId] = variantSet.variants || []
}

this.calibrationClassVariantsByUrn = {
...this.calibrationClassVariantsByUrn,
[calibrationUrn]: variantsByClassificationId
}
} catch (error) {
const detail =
axios.isAxiosError(error) && error.response?.status
? `Request failed with status ${error.response.status}.`
: 'Unable to load class variants for this calibration.'

this.$toast.add({
severity: 'warn',
summary: 'Could not load calibration class variants.',
detail,
life: 4000
})

// Remove any failed calibration from the variants by urn to avoid repeated failed load attempts on re-render or calibration switch.
this.calibrationClassVariantsByUrn = Object.fromEntries(
Object.entries(this.calibrationClassVariantsByUrn).filter(([urn]) => urn !== calibrationUrn)
)
} finally {
this.calibrationClassVariantsLoadingByUrn = {
...this.calibrationClassVariantsLoadingByUrn,
[calibrationUrn]: false
}
}
},
loadClinicalControls: async function () {
if (
this.controlDb &&
Expand Down
44 changes: 44 additions & 0 deletions src/lib/calibrations.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import axios from 'axios'

import config from '@/config'
import {HistogramBin, HistogramShader} from '@/lib/histogram'
import {components} from '@/schema/openapi'

export type FunctionalClassificationVariants = components['schemas']['FunctionalClassificationVariants']
export type FunctionalClassificationVariant = components['schemas']['VariantEffectMeasurement']

export const NORMAL_RANGE_DEFAULT_COLOR = '#4444ff'
export const ABNORMAL_RANGE_DEFAULT_COLOR = '#ff4444'
export const NOT_SPECIFIED_RANGE_DEFAULT_COLOR = '#646464'
Expand Down Expand Up @@ -215,3 +221,41 @@ export function functionalClassificationContainsVariant(

return lowerOk && upperOk
}

/**
* Fetches the full list of variants for a single functional classification in a score calibration.
*
* Uses the dedicated calibration variants endpoint introduced to replace embedded
* `variants` payloads in calibration responses.
*
* @param calibrationUrn The score calibration URN.
* @param classificationId The database ID of the functional classification.
* @returns The classification ID and associated variant list.
*/
export async function fetchScoreCalibrationFunctionalClassificationVariants(
calibrationUrn: string,
classificationId: number
): Promise<FunctionalClassificationVariants> {
const response = await axios.get(
`${config.apiBaseUrl}/score-calibrations/${encodeURIComponent(calibrationUrn)}/functional-classifications/${classificationId}/variants`
)
return response.data
}

/**
* Fetches variant lists for all functional classifications in a score calibration.
*
* This is intended for views that need class membership across the full calibration
* (for example, class-based histogram series generation).
*
* @param calibrationUrn The score calibration URN.
* @returns An array of per-classification variant payloads.
*/
export async function fetchScoreCalibrationVariants(
calibrationUrn: string
): Promise<FunctionalClassificationVariants[]> {
const response = await axios.get(
`${config.apiBaseUrl}/score-calibrations/${encodeURIComponent(calibrationUrn)}/variants`
)
return response.data
}
35 changes: 19 additions & 16 deletions src/lib/histogram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import $ from 'jquery'
import _ from 'lodash'
import {v4 as uuidv4} from 'uuid'
import {HeatmapDatum} from './heatmap'
import { style } from 'd3'

type FieldGetter<T> = ((d: HistogramDatum) => T) | string
type Getter<T> = () => T
Expand All @@ -12,18 +11,18 @@ type Accessor<T, Self> = (value?: T) => T | Self
export const DEFAULT_SHADER_COLOR = '#333333'
export const DEFAULT_SERIES_COLOR = '#333333'
export const CATEGORICAL_SERIES_COLORS = [
'#1f77b4', // blue
'#ff7f0e', // orange
'#2ca02c', // green
'#d62728', // red
'#9467bd', // purple
'#8c564b', // brown
'#e377c2', // pink
'#7f7f7f', // gray
'#bcbd22', // olive
'#17becf', // cyan
'#aec7e8', // light blue
'#ffbb78' // light orange
'#FFD700', // Gold (yellow)
'#32CD32', // Lime (green)
'#FF00FF', // Magenta (purple-pink)
'#00CED1', // Cyan (blue-green)
'#FF8C00', // Orange
'#9370DB', // Purple
'#7FFF00', // Chartreuse (yellow-green)
'#FF6347', // Tomato (orange-red)
'#8B00FF', // Violet (deep purple)
'#20B2AA', // Teal (darker cyan)
'#DA70D6', // Orchid (light purple)
'#FFBF00' // Amber (darker yellow)
]
const LABEL_SIZE = 10

Expand Down Expand Up @@ -616,7 +615,10 @@ export default function makeHistogram(): Histogram {

// Clamp vertically to keep tooltip inside container bounds
selectionTooltip
.style('top', `clamp(${-(_container.clientHeight - bufferPx)}px, ${anchorTop - bufferPx}px, ${-(tooltipHeight + bufferPx)}px)`)
.style(
'top',
`clamp(${-(_container.clientHeight - bufferPx)}px, ${anchorTop - bufferPx}px, ${-(tooltipHeight + bufferPx)}px)`
)
// Prevent the relatively positioned div from affecting layout flow
.style('margin-bottom', `${-height - topBorderWidth * 2}px`)

Expand Down Expand Up @@ -661,7 +663,7 @@ export default function makeHistogram(): Histogram {
return 0
}
// Highlight selected and hovered bins.
return ((hoverBin && d == hoverBin) || d == selectedBin) ? 1 : 0
return (hoverBin && d == hoverBin) || d == selectedBin ? 1 : 0
}

const hoverStrokeWidth = (d: HistogramBin) => {
Expand All @@ -671,7 +673,8 @@ export default function makeHistogram(): Histogram {

const refreshHighlighting = () => {
if (svg) {
svg.selectAll('.histogram-hover-highlight')
svg
.selectAll('.histogram-hover-highlight')
.style('opacity', (d) => hoverOpacity(d as HistogramBin))
.style('stroke-width', (d) => hoverStrokeWidth(d as HistogramBin))
}
Expand Down
Loading