From e287d3d7504404d21794e5b726bddef6daabe4db Mon Sep 17 00:00:00 2001 From: Benjamin Capodanno Date: Thu, 19 Feb 2026 13:54:23 -0800 Subject: [PATCH] feat(calibrations): migrate class variants to lazy endpoints and update class-view handling - consume new calibration variant endpoints via API helpers - switch functional classification usage to id/variantCount model - replace embedded variant counts in calibration table with variantCount - lazily load class-based calibration variants in histogram view with loading state and cache - fix class-view empty state by gating series on loaded class map and triggering refresh on load - add TODO#622 note for future large-dataset performance optimizations --- src/components/CalibrationTable.vue | 4 +- src/components/ScoreSetHistogram.vue | 153 +++++++++++++++++++++++--- src/lib/calibrations.ts | 44 ++++++++ src/lib/histogram.ts | 35 +++--- src/schema/openapi.d.ts | 158 ++++++++++++++++++++------- 5 files changed, 319 insertions(+), 75 deletions(-) diff --git a/src/components/CalibrationTable.vue b/src/components/CalibrationTable.vue index a08be73f..7c2aee4b 100644 --- a/src/components/CalibrationTable.vue +++ b/src/components/CalibrationTable.vue @@ -144,9 +144,7 @@ } ]" > - - {{ range.variants ? range.variants.length : 0 }} - + {{ range.variantCount ?? 0 }} variants diff --git a/src/components/ScoreSetHistogram.vue b/src/components/ScoreSetHistogram.vue index 394d9eb0..1fcc1ad6 100644 --- a/src/components/ScoreSetHistogram.vue +++ b/src/components/ScoreSetHistogram.vue @@ -28,6 +28,10 @@ Loading clinical control options in the background. Additional histogram views will be available once loaded. +
+ + Loading calibration class variants. +
Clinical Series Options @@ -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, @@ -313,6 +318,8 @@ export default defineComponent({ customSelectedControlVariantTypeFilters: DEFAULT_VARIANT_EFFECT_TYPES.concat( this.hideStartAndStopLossByDefault ? [] : ['Start/Stop Loss'] ), + calibrationClassVariantsByUrn: {} as Record>, + calibrationClassVariantsLoadingByUrn: {} as Record, histogram: null as Histogram | null } }, @@ -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 = {} 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 } @@ -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 @@ -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 [ { @@ -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() } @@ -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() @@ -1035,6 +1088,7 @@ export default defineComponent({ this.renderOrRefreshHistogram() this.$emit('exportChart', this.exportChart) this.activeCalibration = this.chooseDefaultCalibration() + await this.conditionallyLoadCalibrationClassVariants() }, beforeUnmount: function () { @@ -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 = {} + + 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 && diff --git a/src/lib/calibrations.ts b/src/lib/calibrations.ts index 12d4a8c7..20548e40 100644 --- a/src/lib/calibrations.ts +++ b/src/lib/calibrations.ts @@ -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' @@ -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 { + 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 { + const response = await axios.get( + `${config.apiBaseUrl}/score-calibrations/${encodeURIComponent(calibrationUrn)}/variants` + ) + return response.data +} diff --git a/src/lib/histogram.ts b/src/lib/histogram.ts index 5c5a0d60..7d9329ac 100644 --- a/src/lib/histogram.ts +++ b/src/lib/histogram.ts @@ -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 = ((d: HistogramDatum) => T) | string type Getter = () => T @@ -12,18 +11,18 @@ type Accessor = (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 @@ -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`) @@ -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) => { @@ -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)) } diff --git a/src/schema/openapi.d.ts b/src/schema/openapi.d.ts index 5bd2934b..7fc97c42 100644 --- a/src/schema/openapi.d.ts +++ b/src/schema/openapi.d.ts @@ -596,6 +596,29 @@ export interface paths { */ post: operations["publish_score_calibration_route_api_v1_score_calibrations__urn__publish_post"]; }; + "/api/v1/score-calibrations/{urn}/functional-classifications/{classification_id}/variants": { + /** + * Get Functional Classification Variants + * @description Retrieve variants for a specific functional classification within a score calibration. + * + * Returns the list of variants whose scores fall within the functional classification's + * defined range or class. Use this endpoint when you need the full variant data for a + * specific classification — the main score set and calibration endpoints return only + * a `variant_count` summary for performance. + */ + get: operations["get_functional_classification_variants_api_v1_score_calibrations__urn__functional_classifications__classification_id__variants_get"]; + }; + "/api/v1/score-calibrations/{urn}/variants": { + /** + * Get Calibration All Variants + * @description Retrieve all variants across all functional classifications for a score calibration. + * + * Returns a list of variant sets, one per functional classification. Use this endpoint + * when you need the full variant data for an entire calibration — the main score set and + * calibration endpoints return only a `variant_count` summary for performance. + */ + get: operations["get_calibration_all_variants_api_v1_score_calibrations__urn__variants_get"]; + }; "/api/v1/score-sets/search": { /** * Search score sets @@ -3148,6 +3171,19 @@ export interface components { /** Positivelikelihoodratio */ positiveLikelihoodRatio?: number | null; }; + /** + * FunctionalClassificationVariants + * @description Response model for functional classification variant endpoints. + */ + FunctionalClassificationVariants: { + /** Functionalclassificationid */ + functionalClassificationId: number; + /** + * Variants + * @default [] + */ + variants?: components["schemas"]["VariantEffectMeasurement"][]; + }; /** * GnomADVariantWithMappedVariants * @description GnomAD variant view model with mapped variants for non-admin clients. @@ -3940,13 +3976,15 @@ export interface components { oddspathsRatio?: number | null; /** Positivelikelihoodratio */ positiveLikelihoodRatio?: number | null; + /** Id */ + id: number; /** Recordtype */ recordType?: string; /** - * Variants - * @default [] + * Variantcount + * @default 0 */ - variants?: components["schemas"]["SavedVariantEffectMeasurement"][]; + variantCount?: number; }; /** SavedPublicationIdentifier */ SavedPublicationIdentifier: { @@ -4048,38 +4086,6 @@ export interface components { /** Recordtype */ recordType?: string; }; - /** - * SavedVariantEffectMeasurement - * @description Base class for variant effect measurement view models handling saved variant effect measurements - */ - SavedVariantEffectMeasurement: { - /** Urn */ - urn?: string | null; - /** Data */ - data: unknown; - /** Scoresetid */ - scoreSetId: number; - /** Hgvsnt */ - hgvsNt?: string | null; - /** Hgvspro */ - hgvsPro?: string | null; - /** Hgvssplice */ - hgvsSplice?: string | null; - /** - * Creationdate - * Format: date - */ - creationDate: string; - /** - * Modificationdate - * Format: date - */ - modificationDate: string; - /** Id */ - id: number; - /** Recordtype */ - recordType?: string; - }; /** * ScoreCalibration * @description Complete score calibration model returned by the API. @@ -5763,13 +5769,15 @@ export interface components { oddspathsRatio?: number | null; /** Positivelikelihoodratio */ positiveLikelihoodRatio?: number | null; + /** Id */ + id: number; /** Recordtype */ recordType?: string; /** - * Variants - * @default [] + * Variantcount + * @default 0 */ - variants?: components["schemas"]["VariantEffectMeasurement"][]; + variantCount?: number; }; /** * sequenceString @@ -8643,6 +8651,80 @@ export interface operations { }; }; }; + /** + * Get Functional Classification Variants + * @description Retrieve variants for a specific functional classification within a score calibration. + * + * Returns the list of variants whose scores fall within the functional classification's + * defined range or class. Use this endpoint when you need the full variant data for a + * specific classification — the main score set and calibration endpoints return only + * a `variant_count` summary for performance. + */ + get_functional_classification_variants_api_v1_score_calibrations__urn__functional_classifications__classification_id__variants_get: { + parameters: { + header?: { + "x-active-roles"?: string | null; + }; + path: { + urn: string; + classification_id: number; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["FunctionalClassificationVariants"]; + }; + }; + /** @description Not Found */ + 404: { + content: never; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + /** + * Get Calibration All Variants + * @description Retrieve all variants across all functional classifications for a score calibration. + * + * Returns a list of variant sets, one per functional classification. Use this endpoint + * when you need the full variant data for an entire calibration — the main score set and + * calibration endpoints return only a `variant_count` summary for performance. + */ + get_calibration_all_variants_api_v1_score_calibrations__urn__variants_get: { + parameters: { + header?: { + "x-active-roles"?: string | null; + }; + path: { + urn: string; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["FunctionalClassificationVariants"][]; + }; + }; + /** @description Not Found */ + 404: { + content: never; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; /** * Search score sets * @description Search score sets.