-
Assay OddsPath
-
-
- Abnormal: {{ abnormalOddsPath ?? '\u2014' }}
-
-
- Normal: {{ normalOddsPath ?? '\u2014' }}
-
-
+
+ Functional score
+ {{ formatScore(score) }}
-
Classified as
-
+
Functional impact
+
+ (OddsPath {{ oddspathsRatio.toPrecision(3) }})
-
-
No calibration data
+
+ No primary classification
@@ -56,7 +74,11 @@ import {defineComponent, type PropType} from 'vue'
import MvClassificationTag from '@/components/common/MvClassificationTag.vue'
import MvEvidenceTag from '@/components/common/MvEvidenceTag.vue'
-import {MEASUREMENT_TYPE_CLASSES, MEASUREMENT_TYPE_LABELS, type MeasurementType} from '@/lib/measurement-types'
+import {assayLevelDisplay, RELATIONSHIPS, type MeasurementRelationship} from '@/lib/measurement-types'
+import {formatScore} from '@/lib/scores'
+import type {components} from '@/schema/openapi'
+
+type SavedFunctionalClassification = components['schemas']['SavedFunctionalClassification']
export default defineComponent({
name: 'MvMeasurementCard',
@@ -68,27 +90,54 @@ export default defineComponent({
props: {
active: {type: Boolean, default: false},
- type: {type: String as PropType
, required: true},
- studyTitle: {type: String, default: ''},
+ // Shown only on the active card, while the selected variant's detail/scores are still loading.
+ loading: {type: Boolean, default: false},
+ // The assayed level (AnnotationLayer: protein/cdna/genomic) — drives the level badge.
+ assayLevel: {type: [String, null] as PropType, default: null},
+ // Whether the measurement assayed the queried ClinGen allele itself (direct) or a related one (indirect).
+ relationship: {type: String as PropType, required: true},
+ // Score set attributes displayed on the card body.
+ scoreSetTitle: {type: String, default: ''},
+ scoreSetUrn: {type: [String, null] as PropType, default: null},
+ score: {type: [Number, null] as PropType, default: null},
assayType: {type: [String, null] as PropType, default: null},
mechanism: {type: [String, null] as PropType, default: null},
modelSystem: {type: [String, null] as PropType, default: null},
- abnormalOddsPath: {type: [String, null] as PropType, default: null},
- normalOddsPath: {type: [String, null] as PropType, default: null},
- classification: {type: [String, null] as PropType, default: null},
- evidenceCode: {type: [String, null] as PropType, default: null}
+ // The server-preferred functional classification, inline on the measurement (no scores fetch needed).
+ preferredClassification: {
+ type: [Object, null] as PropType,
+ default: null
+ }
},
emits: ['select'],
computed: {
levelLabel(): string {
- return MEASUREMENT_TYPE_LABELS[this.type]?.full ?? this.type
+ return assayLevelDisplay(this.assayLevel).label
},
levelClass(): string {
- return MEASUREMENT_TYPE_CLASSES[this.type] ?? ''
+ return assayLevelDisplay(this.assayLevel).class
+ },
+ relationshipLabel(): string {
+ return RELATIONSHIPS[this.relationship]?.label ?? this.relationship
},
- }
+ relationshipClass(): string {
+ return RELATIONSHIPS[this.relationship]?.class ?? 'bg-border-light text-text-muted'
+ },
+ classification(): string | null {
+ return this.preferredClassification?.functionalClassification ?? null
+ },
+ evidenceCode(): string | null {
+ const acmg = this.preferredClassification?.acmgClassification
+ if (!acmg?.evidenceStrength) return null
+ return `${acmg.criterion}_${acmg.evidenceStrength.toUpperCase()}`
+ },
+ oddspathsRatio(): number | null {
+ return this.preferredClassification?.oddspathsRatio ?? null
+ }
+ },
+ methods: {formatScore}
})
diff --git a/src/components/variant/VariantClinvarStat.vue b/src/components/variant/VariantClinvarStat.vue
new file mode 100644
index 00000000..36db81d2
--- /dev/null
+++ b/src/components/variant/VariantClinvarStat.vue
@@ -0,0 +1,218 @@
+
+
+
+
ClinVar
+
+
+ Conflicting classifications
+
+
+
+
+
+ ClinVar marks a record with the same protein change as conflicting.
+
+
+ ClinVar marks a record with the same protein change as uncertain.
+
+
+ Representative of concordant records with the same protein change.
+
+
+
+
+
+
+ No ClinVar record for this variant
+
+
—
+
+
+
+
+
+
+ inferred from {{ underlyingClinvar.length }} related
+ {{ underlyingClinvar.length === 1 ? 'variant' : 'variants' }}
+
+ {{ underlyingClinvar.length }} {{ underlyingLabel }}
+
+
+
+
{{ underlyingClinvarNote }}
+
+
+ {{ item.hgvs }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/variant/VariantConsequenceStat.vue b/src/components/variant/VariantConsequenceStat.vue
new file mode 100644
index 00000000..646981b3
--- /dev/null
+++ b/src/components/variant/VariantConsequenceStat.vue
@@ -0,0 +1,75 @@
+
+
+
+ Molecular consequence
+
+ {{ formatConsequence(consequence) }}
+ As of VEP version {{ sourceVersion }}
+
+ —
+
+
+
+
+
+
diff --git a/src/components/variant/VariantDetailPanel.vue b/src/components/variant/VariantDetailPanel.vue
new file mode 100644
index 00000000..fb9381d6
--- /dev/null
+++ b/src/components/variant/VariantDetailPanel.vue
@@ -0,0 +1,312 @@
+
+
+
+
+
+
+
+ Could not load variant detail.
+ Retry
+
+
+
+
+
+ {{ coordinate || detail.urn }}
+
+ {{ underlyingCoordinate }}
+
+
+ Score
+ {{
+ formatScore(score)
+ }}
+ Not scored
+
+
+
+
+
+
+
+ Functional impact
+
+
+ {{ formatToken(selectedClassification.classification.functionalClassification) }}
+
+
+
+ —
+
+
+ OddsPath
+ {{ oddspathsRatio }}
+
+
+
+
+
+
+
+
+
+
+ This amino-acid change reverse-translates to {{ candidateFanoutCount }} candidate nucleotide allele{{
+ candidateFanoutCount === 1 ? '' : 's'
+ }}; no population or clinical evidence was found for this allele or the candidate nucleotide alleles.
+
+
+ No reference annotations were found for this allele.
+
+
+
+
+
+
+ Superseded
+
+
+ View full details
+
+
+
+
+
+
+
+
+
diff --git a/src/components/variant/VariantGnomadStat.vue b/src/components/variant/VariantGnomadStat.vue
new file mode 100644
index 00000000..20d3b627
--- /dev/null
+++ b/src/components/variant/VariantGnomadStat.vue
@@ -0,0 +1,181 @@
+
+
+
+
gnomAD
+
+
+
+
Up to AF {{ formatFrequency(underlyingGnomadMaxAf) }}
+
+
+ No gnomAD record for this allele
+
+
—
+
+
+
+
+
+
+ pooled from {{ underlyingGnomad.length }} related
+ {{ underlyingGnomad.length === 1 ? 'variant' : 'variants' }}
+
+ {{ underlyingGnomad.length }} {{ underlyingLabel }}
+
+
+
+
{{ underlyingGnomadNote }}
+
+ {{ item.hgvs }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/variant/VariantInfoSection.vue b/src/components/variant/VariantInfoSection.vue
deleted file mode 100644
index 7001dfac..00000000
--- a/src/components/variant/VariantInfoSection.vue
+++ /dev/null
@@ -1,71 +0,0 @@
-
-
-
Variant Information
-
-
- {{ clingenAlleleId }}
-
-
-
- {{
- cvId
- }}
- ,
-
-
-
-
-
-
-
- used to derive this calibration
-
-
-
-
-
- chr{{ loc.chromosome }}:{{ Number(loc.start).toLocaleString() }}
-
-
- ({{ loc.referenceGenome }})
-
-
-
-
-
-
-
diff --git a/src/composables/entity-cache.ts b/src/composables/entity-cache.ts
index d1323eae..7e57a4bb 100644
--- a/src/composables/entity-cache.ts
+++ b/src/composables/entity-cache.ts
@@ -112,7 +112,6 @@ export function useEntityCache(): UseEntityCacheReturn {
throw new Error(`Unknown entity type: ${entityType}`)
}
- console.log(endpoint)
const data =
endpoint === 'score-sets'
? await getBufferedScoreSet(urn)
diff --git a/src/composables/use-calibration-resolution.ts b/src/composables/use-calibration-resolution.ts
index 1304c950..ae58cd74 100644
--- a/src/composables/use-calibration-resolution.ts
+++ b/src/composables/use-calibration-resolution.ts
@@ -1,7 +1,8 @@
import {computed, ref, watch, type ComputedRef, type Ref} from 'vue'
import {getScoreCalibrationVariants} from '@/api/mavedb/calibrations'
-import {formatEvidenceCode, functionalClassificationContainsVariant} from '@/lib/calibrations'
+import {formatEvidenceCode} from '@/lib/acmg'
+import {functionalClassificationContainsVariant} from '@/lib/calibrations'
import type {components} from '@/schema/openapi'
type ScoreCalibration = components['schemas']['ScoreCalibration']
diff --git a/src/composables/use-clingen-allele.ts b/src/composables/use-clingen-allele.ts
index ab28058d..537bdd68 100644
--- a/src/composables/use-clingen-allele.ts
+++ b/src/composables/use-clingen-allele.ts
@@ -12,7 +12,7 @@ export interface GenomicLocation {
export interface UseClingenAlleleReturn {
allele: Ref
alleleName: ComputedRef
- genomicLocationText: ComputedRef
+ allGenomicLocationsText: ComputedRef
genomicLocations: ComputedRef
clinvarAlleleIds: ComputedRef
fetchAllele: () => Promise
@@ -54,21 +54,21 @@ export function useClingenAllele(clingenAlleleId: Ref): UseClingenAllele
}))
)
- const genomicLocationText = computed(() => {
+ // Every mapped reference-genome build a variant mapped to.
+ const allGenomicLocationsText = computed(() => {
const locs = genomicLocations.value
if (locs.length === 0) return null
- const loc = locs[0]
- return `chr${loc.chromosome}:${Number(loc.start).toLocaleString()} (${loc.referenceGenome})`
+ return locs
+ .map((loc) => `chr${loc.chromosome}:${Number(loc.start).toLocaleString()} (${loc.referenceGenome})`)
+ .join(', ')
})
- const clinvarAlleleIds = computed(() =>
- (allele.value?.externalRecords?.ClinVarAlleles || []).map((a) => a.alleleId)
- )
+ const clinvarAlleleIds = computed(() => (allele.value?.externalRecords?.ClinVarAlleles || []).map((a) => a.alleleId))
return {
allele,
alleleName,
- genomicLocationText,
+ allGenomicLocationsText,
genomicLocations,
clinvarAlleleIds,
fetchAllele
diff --git a/src/composables/use-clinvar-controls.test.ts b/src/composables/use-clinvar-controls.test.ts
new file mode 100644
index 00000000..75022790
--- /dev/null
+++ b/src/composables/use-clinvar-controls.test.ts
@@ -0,0 +1,86 @@
+import {effectScope, nextTick, ref} from 'vue'
+import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'
+
+import axios from 'axios'
+
+import {useClinvarControls} from '@/composables/use-clinvar-controls'
+import type {DisplayVariant} from '@/lib/variants'
+
+vi.mock('axios')
+
+const OPTIONS = [{dbName: 'ClinVar', availableVersions: ['clinvar_2025']}]
+const CONTROLS = [
+ {
+ dbName: 'ClinVar',
+ dbVersion: 'clinvar_2025',
+ dbIdentifier: '12345',
+ clinicalSignificance: 'Pathogenic',
+ clinicalReviewStatus: 'criteria provided, single submitter',
+ clinvarLinks: [{variantUrn: 'urn:mavedb:1#1'}]
+ }
+]
+
+function mockAxios() {
+ ;(axios.get as unknown as ReturnType).mockImplementation((url: string) => {
+ if (url.endsWith('/clinical-controls/options')) return Promise.resolve({status: 200, data: OPTIONS})
+ if (url.includes('/clinical-controls')) return Promise.resolve({status: 200, data: CONTROLS})
+ return Promise.reject(new Error(`unexpected url ${url}`))
+ })
+}
+
+async function settle(ticks = 6) {
+ for (let i = 0; i < ticks; i++) {
+ await nextTick()
+ await Promise.resolve()
+ }
+}
+
+describe('useClinvarControls', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockAxios()
+ })
+ afterEach(() => {
+ vi.restoreAllMocks()
+ mockAxios()
+ })
+
+ test('variants present before controls: associates and flips someVariants', async () => {
+ const variants = ref([
+ {variantUrn: 'urn:mavedb:1#1', score: -2} as unknown as DisplayVariant,
+ {variantUrn: 'urn:mavedb:1#2', score: 0.1} as unknown as DisplayVariant
+ ])
+ const scope = effectScope()
+ let store!: ReturnType
+ scope.run(() => {
+ store = useClinvarControls(ref('urn:mavedb:1'), variants)
+ })
+ await settle()
+
+ expect(store.refreshed).toBe(true)
+ expect(store.controls.length).toBe(1)
+ expect(store.someVariantsHaveClinicalSignificance).toBe(true)
+ expect((variants.value![0] as {control?: unknown}).control).toBeTruthy()
+ expect((variants.value![1] as {control?: unknown}).control ?? null).toBeNull()
+ scope.stop()
+ })
+
+ test('variants arrive AFTER controls: re-associates when variants populate', async () => {
+ const variants = ref(null)
+ const scope = effectScope()
+ let store!: ReturnType
+ scope.run(() => {
+ store = useClinvarControls(ref('urn:mavedb:1'), variants)
+ })
+ await settle()
+ // Controls loaded, but no variants yet to associate.
+ expect(store.controls.length).toBe(1)
+ expect(store.someVariantsHaveClinicalSignificance).toBe(false)
+
+ variants.value = [{variantUrn: 'urn:mavedb:1#1', score: -2} as unknown as DisplayVariant]
+ await settle()
+ expect(store.someVariantsHaveClinicalSignificance).toBe(true)
+ expect((variants.value[0] as {control?: unknown}).control).toBeTruthy()
+ scope.stop()
+ })
+})
diff --git a/src/composables/use-clinvar-controls.ts b/src/composables/use-clinvar-controls.ts
new file mode 100644
index 00000000..4965377f
--- /dev/null
+++ b/src/composables/use-clinvar-controls.ts
@@ -0,0 +1,216 @@
+import axios from 'axios'
+import {reactive, watch, watchEffect, type Ref} from 'vue'
+
+import config from '@/config'
+import {reduceControlPlacement, type ControlLink} from '@/lib/clinvar-control-placement'
+import {DEFAULT_CLINVAR_CONTROL_DB, DEFAULT_CLNREVSTAT_FIELD, DEFAULT_CLNSIG_FIELD} from '@/lib/clinvar-controls'
+import type {ClinvarControl, ClinvarControlOption} from '@/lib/clinvar-controls'
+import type {DisplayVariant} from '@/lib/variants'
+
+/**
+ * Shared clinical-control state for a single score set. The fetch is keyed purely by (db, version) — the
+ * interactive minimum-star filter stays a per-consumer display concern and is NOT part of this store.
+ *
+ * The store owns db/version selection, the controls fetch (cached per db+version), and mutation of
+ * `variant.control` onto the passed-in variants. It self-drives via watchers on `urn` and `variants`;
+ * consumers only read state and (for the histogram's selectors) two-way bind `controlDb`/`controlVersion`.
+ */
+export interface ClinvarControlsStore {
+ /** Available (db, versions) pairs for this score set. */
+ options: ClinvarControlOption[]
+ /** Controls for the currently selected (db, version). */
+ controls: ClinvarControl[]
+ /** Selected control database. Two-way bound by the histogram's DB selector. */
+ controlDb: ClinvarControlOption | null
+ /** Selected control version. Two-way bound by the histogram's version selector. */
+ controlVersion: string | null
+ /** True once an options+controls fetch cycle has settled (drives loading spinners / gating). */
+ refreshed: boolean
+ /** True once controls have been associated onto the variants (or a load failed). */
+ associated: boolean
+ /** True when at least one variant carries a matched clinvar control. */
+ someVariantsHaveClinicalSignificance: boolean
+ /** Derived: whether a DB/version selector is worth showing (more than one choice exists). Read-only in practice. */
+ showOptions: boolean
+}
+
+export function useClinvarControls(
+ urn: Ref,
+ variants: Ref
+): ClinvarControlsStore {
+ const state: ClinvarControlsStore = reactive({
+ options: [] as ClinvarControlOption[],
+ controls: [] as ClinvarControl[],
+ controlDb: null as ClinvarControlOption | null,
+ controlVersion: null as string | null,
+ refreshed: false,
+ associated: false,
+ someVariantsHaveClinicalSignificance: false,
+ showOptions: false
+ })
+
+ // A DB/version selector is only worth showing when there's more than one choice.
+ watchEffect(() => {
+ const hasMultipleDbs = state.options.length > 1
+ const hasSingleDbWithMultipleVersions = state.options.length === 1 && state.options[0].availableVersions.length > 1
+ state.showOptions = hasMultipleDbs || hasSingleDbWithMultipleVersions
+ })
+
+ // Non-reactive controls cache, keyed [dbName][version]. Rebuilt whenever the options change.
+ let cache: Record> = {}
+
+ async function loadOptions() {
+ const scoreSetUrn = urn.value
+ if (!scoreSetUrn) {
+ return
+ }
+ try {
+ const response = await axios.get(`${config.apiBaseUrl}/score-sets/${scoreSetUrn}/clinical-controls/options`)
+ if (response.status === 200) {
+ state.options = response.data
+ }
+ } catch {
+ // Still settle the flags so loading spinners clear and dependent views fall into an empty state.
+ state.refreshed = true
+ state.associated = true
+ }
+ }
+
+ async function loadControls() {
+ if (state.controlDb && state.controlVersion && cache[state.controlDb.dbName]?.[state.controlVersion].length > 0) {
+ state.controls = cache[state.controlDb.dbName][state.controlVersion]
+ state.refreshed = true
+ return
+ }
+
+ state.refreshed = false
+ let queryString = ''
+ if (state.controlDb) {
+ queryString += `?db=${encodeURIComponent(state.controlDb.dbName)}`
+ }
+ if (state.controlVersion) {
+ queryString += queryString
+ ? `&version=${encodeURIComponent(state.controlVersion)}`
+ : `?version=${encodeURIComponent(state.controlVersion)}`
+ }
+
+ const scoreSetUrn = urn.value
+ if (scoreSetUrn) {
+ try {
+ const response = await axios.get(
+ `${config.apiBaseUrl}/score-sets/${scoreSetUrn}/clinical-controls${queryString}`
+ )
+ if (response.data) {
+ state.controls = response.data
+ if (state.controlDb && state.controlVersion) {
+ cache[state.controlDb.dbName][state.controlVersion] = response.data
+ }
+ }
+ } catch {
+ state.associated = true
+ }
+ }
+ state.refreshed = true
+ }
+
+ function disassociate() {
+ state.associated = false
+ state.someVariantsHaveClinicalSignificance = false
+ for (const variant of variants.value ?? []) {
+ variant.control = null
+ }
+ }
+
+ function associate() {
+ const list = variants.value ?? []
+
+ // Gather every control reaching each variant, tagged with the digest of the allele it annotates, then
+ // reduce based on precedence + hard/soft discordance. A protein-change variant whose encodings
+ // disagree can be reached by several controls; the fold — not last-write-wins — decides its placement.
+ const linksByUrn = new Map()
+ for (const control of state.controls) {
+ for (const clinvarLink of control.clinvarLinks) {
+ if (!clinvarLink.variantUrn) {
+ continue
+ }
+ const links = linksByUrn.get(clinvarLink.variantUrn) ?? []
+ links.push({
+ significance: control[DEFAULT_CLNSIG_FIELD],
+ reviewStatus: control[DEFAULT_CLNREVSTAT_FIELD],
+ alleleDigest: clinvarLink.alleleDigest,
+ dbIdentifier: control.dbIdentifier
+ })
+ linksByUrn.set(clinvarLink.variantUrn, links)
+ }
+ }
+
+ let usableAny = false
+ for (const variant of list) {
+ const links = linksByUrn.get(variant.variantUrn)
+ const placement = links ? reduceControlPlacement(links, variant.assayLevelDigest, variant.assayLevel) : null
+ variant.control = placement
+ // "Has clinical significance" gates the clinical view — a hard-discordant variant carries ClinVar
+ // data but is not a usable control, so it doesn't count toward showing the view.
+ if (placement && placement.discordance !== 'hard') {
+ usableAny = true
+ }
+ }
+ state.associated = true
+ state.someVariantsHaveClinicalSignificance = usableAny
+ }
+
+ // A new score set resets everything and refetches the available options.
+ watch(
+ urn,
+ () => {
+ state.options = []
+ state.controls = []
+ state.controlDb = null
+ state.controlVersion = null
+ state.refreshed = false
+ state.associated = false
+ state.someVariantsHaveClinicalSignificance = false
+ cache = {}
+ loadOptions()
+ },
+ {immediate: true}
+ )
+
+ // Fresh options pick a default db+version (preferring ClinVar) and rebuild the cache skeleton.
+ watch(
+ () => state.options,
+ () => {
+ if (!state.controlDb) {
+ const defaultDb = state.options.find((option) => option.dbName === DEFAULT_CLINVAR_CONTROL_DB)
+ state.controlDb = defaultDb ? defaultDb : (state.options[0] ?? null)
+ }
+ if (!state.controlVersion) {
+ state.controlVersion = state.controlDb?.availableVersions[0] ?? null
+ }
+ const next: Record> = {}
+ for (const dbOption of state.options) {
+ next[dbOption.dbName] = {}
+ for (const version of dbOption.availableVersions) {
+ next[dbOption.dbName][version] = []
+ }
+ }
+ cache = next
+ }
+ )
+
+ // Any change to the selected (db, version) reloads controls.
+ watch(
+ () => `${state.controlDb?.dbName}|${state.controlVersion}`,
+ () => {
+ loadControls()
+ }
+ )
+
+ // New controls (or a new variant set arriving) re-associates onto the variants.
+ watch([() => state.controls, variants], () => {
+ disassociate()
+ associate()
+ })
+
+ return state
+}
diff --git a/src/composables/use-key-drawer.ts b/src/composables/use-key-drawer.ts
new file mode 100644
index 00000000..248176de
--- /dev/null
+++ b/src/composables/use-key-drawer.ts
@@ -0,0 +1,45 @@
+import {ref} from 'vue'
+
+/** One defined term shown in the Key drawer. `class` styles the term chip (e.g. to match its badge). */
+export interface KeyTerm {
+ label: string
+ definition: string
+ class?: string
+}
+
+/**
+ * A titled group of related terms. `id` is the deep-link anchor: a badge opens the drawer with
+ * `open(id)` and the drawer scrolls to `#key-` and flashes it. `docsUrl` optionally overrides the
+ * drawer-level docs link for this section's "more →".
+ */
+export interface KeySection {
+ id: string
+ title: string
+ gloss?: string
+ terms: KeyTerm[]
+ docsUrl?: string
+}
+
+/**
+ * Module-level singleton driving the vocabulary "Key" drawer. Any component can call `open(term)` to
+ * surface a definition without threading props/events through the page — the drawer itself is mounted
+ * once at the app root and reads this shared state. `activeTerm` is the anchor id the drawer
+ * scrolls to and briefly highlights on open; passing nothing opens it at the top.
+ *
+ * Deliberately global rather than provide/inject: the trigger (control header) and the deep-linking
+ * badges (measurement cards, per-level annotations) live in unrelated subtrees, and there is only ever
+ * one Key drawer on screen.
+ */
+const isOpen = ref(false)
+const activeTerm = ref(null)
+
+export function useKeyDrawer() {
+ function open(term?: string) {
+ activeTerm.value = term ?? null
+ isOpen.value = true
+ }
+ function close() {
+ isOpen.value = false
+ }
+ return {isOpen, activeTerm, open, close}
+}
diff --git a/src/composables/use-measurement-cache.ts b/src/composables/use-measurement-cache.ts
new file mode 100644
index 00000000..c35288c7
--- /dev/null
+++ b/src/composables/use-measurement-cache.ts
@@ -0,0 +1,117 @@
+import {shallowRef, type Ref} from 'vue'
+
+import {getVariantDetail} from '@/api/mavedb/variants'
+import {getLeanScoreSetVariants, getScoreSet} from '@/api/mavedb/score-sets'
+import type {DisplayVariant} from '@/lib/variants'
+import type {components} from '@/schema/openapi'
+
+type ScoreSet = components['schemas']['ScoreSet']
+type VariantDetail = components['schemas']['VariantDetail']
+
+// A variant URN is `#`, so its score set is the URN's prefix — no extra lookup needed.
+export const scoreSetUrnOf = (variantUrn: string): string => variantUrn.split('#')[0]
+
+export interface UseMeasurementCacheReturn {
+ variantDetails: Ref>
+ scoreSets: Ref>
+ scores: Ref>
+ loadDetail: (variantUrn: string) => Promise
+ loadScores: (scoreSetUrn: string) => Promise
+ // In-flight flags for loading affordances.
+ isDetailLoading: (variantUrn: string) => boolean
+ isScoresLoading: (scoreSetUrn: string) => boolean
+ clear: () => void
+}
+
+/**
+ * Per-URN cache for a measurement's detail envelope, its score set, and its lean score distribution.
+ *
+ * `loadDetail` resolves the envelope + its score set for a variant URN; `loadScores` fetches the heavier
+ * lean score distribution separately. Score set + scores are keyed by the URN's score-set prefix and
+ * shared across measurements from the same assay. Request dedup + reads are memoized in the api layer
+ * (@/api/cache); these Records are just the reactive projection the template renders. `clear()` drops
+ * everything for a new query epoch (a changed `as_of` must re-resolve the molecular/annotation layer).
+ *
+ * Used by: useVariantLookup, useMeasurementSelection
+ */
+export function useMeasurementCache(asOf: Ref): UseMeasurementCacheReturn {
+ const variantDetails = shallowRef>({})
+ const scoreSets = shallowRef>({})
+ const scores = shallowRef>({})
+
+ // In-flight keys, tracked so consumers can show a per-selection loading affordance. Reassigned (not
+ // mutated) so the shallowRefs stay reactive.
+ const pendingDetails = shallowRef>(new Set())
+ const pendingScores = shallowRef>(new Set())
+ function setPending(pending: Ref>, key: string, active: boolean) {
+ if (pending.value.has(key) === active) return
+ const next = new Set(pending.value)
+ if (active) next.add(key)
+ else next.delete(key)
+ pending.value = next
+ }
+
+ async function loadScoreSet(scoreSetUrn: string) {
+ if (scoreSets.value[scoreSetUrn]) return
+ try {
+ // Await into a local first, then spread — writing `{...scoreSets.value, [k]: await …}` would
+ // capture the spread baseline before the await resolves, so two concurrent loaders (the selected
+ // measurement + a prefetch) both snapshot the same pre-write map and the later write clobbers the
+ // earlier key.
+ const scoreSet = await getScoreSet(scoreSetUrn)
+ scoreSets.value = {...scoreSets.value, [scoreSetUrn]: scoreSet}
+ } catch (error) {
+ console.error(`Error fetching score set "${scoreSetUrn}"`, error)
+ }
+ }
+
+ async function loadScores(scoreSetUrn: string) {
+ if (scores.value[scoreSetUrn]) return
+ setPending(pendingScores, scoreSetUrn, true)
+ try {
+ // Await into a local before spreading — see loadScoreSet for why an inline `await` in the spread
+ // literal races and drops keys under concurrent loads.
+ const leanVariants = await getLeanScoreSetVariants(scoreSetUrn)
+ scores.value = {...scores.value, [scoreSetUrn]: leanVariants}
+ } catch (error) {
+ console.error(`Error fetching scores for score set "${scoreSetUrn}"`, error)
+ } finally {
+ setPending(pendingScores, scoreSetUrn, false)
+ }
+ }
+
+ async function loadDetail(variantUrn: string) {
+ const scoreSetUrn = scoreSetUrnOf(variantUrn)
+ // The score set backs certain metadata displayed alongside variant detail.
+ loadScoreSet(scoreSetUrn)
+ if (variantDetails.value[variantUrn]) return
+ setPending(pendingDetails, variantUrn, true)
+ try {
+ const detail = await getVariantDetail(variantUrn, {asOf: asOf.value ?? undefined})
+ variantDetails.value = {...variantDetails.value, [variantUrn]: detail}
+ } catch (error) {
+ console.error(`Error fetching variant detail for "${variantUrn}"`, error)
+ } finally {
+ setPending(pendingDetails, variantUrn, false)
+ }
+ }
+
+ function clear() {
+ variantDetails.value = {}
+ scoreSets.value = {}
+ scores.value = {}
+ pendingDetails.value = new Set()
+ pendingScores.value = new Set()
+ }
+
+ return {
+ variantDetails,
+ scoreSets,
+ scores,
+ loadDetail,
+ loadScores,
+ isDetailLoading: (variantUrn: string) => pendingDetails.value.has(variantUrn),
+ isScoresLoading: (scoreSetUrn: string) => pendingScores.value.has(scoreSetUrn),
+ clear
+ }
+}
diff --git a/src/composables/use-measurement-selection.ts b/src/composables/use-measurement-selection.ts
new file mode 100644
index 00000000..a0d2a8f2
--- /dev/null
+++ b/src/composables/use-measurement-selection.ts
@@ -0,0 +1,159 @@
+import {computed, ref, watch, type ComputedRef, type Ref} from 'vue'
+
+import {useCalibrationResolution, type UseCalibrationResolutionReturn} from '@/composables/use-calibration-resolution'
+import {scoreSetUrnOf, type UseMeasurementCacheReturn} from '@/composables/use-measurement-cache'
+import {chooseDefaultCalibration} from '@/lib/calibrations'
+import type {DisplayVariant} from '@/lib/variants'
+import type {components} from '@/schema/openapi'
+
+type ScoreCalibration = components['schemas']['ScoreCalibration']
+type ScoreSet = components['schemas']['ScoreSet']
+type AlleleMeasurement = components['schemas']['AlleleMeasurement']
+type VariantDetail = components['schemas']['VariantDetail']
+
+export interface UseMeasurementSelectionReturn {
+ selectedVariantUrn: Ref
+ selectVariant: (urn: string | null | undefined) => void
+ selectedVariant: ComputedRef
+ selectedVariantDetail: ComputedRef
+ selectedVariantName: ComputedRef
+ selectedClingenAlleleId: ComputedRef
+ selectedScoreSet: ComputedRef
+ selectedScoreSetUrn: ComputedRef
+ scores: ComputedRef
+ variantScoreRow: ComputedRef
+ selectedVariantScore: ComputedRef
+ selectedCalibration: Ref
+ selectedCalibrationObject: ComputedRef
+ calibrationResolution: UseCalibrationResolutionReturn
+ // True while the selected measurement's detail or lean scores are still in flight.
+ selectedLoading: ComputedRef
+}
+
+/**
+ * Selection state for the measurements list: which measurement is active and everything derived from it
+ * (detail envelope, score set, score distribution, calibration resolution).
+ *
+ * Reconciles the selection against list changes — honors the `?variant=` highlight, falls back to the
+ * first visible measurement when a filter hides the active one, and loads the selected measurement's
+ * detail on change. Reads cached detail/score-set/scores from {@link useMeasurementCache}; the caller
+ * owns the list and seeds the initial selection by writing `selectedVariantUrn` after a fetch.
+ *
+ * Used by: useVariantLookup
+ */
+export function useMeasurementSelection(
+ variants: Ref,
+ filteredVariants: ComputedRef,
+ highlightUrn: Ref,
+ cache: UseMeasurementCacheReturn
+): UseMeasurementSelectionReturn {
+ const selectedVariantUrn = ref(null)
+ const selectedCalibration = ref(null)
+
+ const selectedVariant = computed(() => variants.value.find((m) => m.variantUrn === selectedVariantUrn.value))
+ const selectedLoading = computed(() => {
+ const urn = selectedVariantUrn.value
+ if (!urn) return false
+ return cache.isDetailLoading(urn) || cache.isScoresLoading(scoreSetUrnOf(urn))
+ })
+ const selectedVariantDetail = computed(() => {
+ if (!selectedVariantUrn.value) return null
+ return cache.variantDetails.value[selectedVariantUrn.value] || null
+ })
+ const selectedScoreSet = computed(() => {
+ if (!selectedVariantUrn.value) return null
+ return cache.scoreSets.value[scoreSetUrnOf(selectedVariantUrn.value)] || null
+ })
+ const selectedVariantName = computed(() => {
+ const m = selectedVariant.value
+ if (m) return m.assayLevelHgvs || m.submittedHgvs || null
+ const detail = selectedVariantDetail.value
+ return detail?.referenceHgvs || detail?.targetHgvs || null
+ })
+ // The detail envelope carries the measured allele's ClinGen id as a flat field (centers the measured
+ // level — a related measurement shows its OWN CAID/PAID, not the queried anchor's).
+ const selectedClingenAlleleId = computed(() => selectedVariantDetail.value?.clingenAlleleId || null)
+
+ const selectedScoreSetUrn = computed(() => selectedScoreSet.value?.urn || null)
+ const scores = computed(() => {
+ if (!selectedScoreSetUrn.value) return null
+ return cache.scores.value[selectedScoreSetUrn.value] || null
+ })
+ const variantScoreRow = computed(() => (scores.value || []).find((s) => s.variantUrn === selectedVariantUrn.value))
+ const selectedVariantScore = computed(() => selectedVariant.value?.score ?? null)
+
+ const selectedCalibrationObject = computed(() => {
+ if (!selectedCalibration.value || !selectedScoreSet.value?.scoreCalibrations) return null
+ return (
+ selectedScoreSet.value.scoreCalibrations.find((c: ScoreCalibration) => c.urn === selectedCalibration.value) ||
+ null
+ )
+ })
+
+ const calibrationResolution = useCalibrationResolution(
+ selectedCalibrationObject,
+ selectedVariantUrn,
+ selectedVariantScore
+ )
+
+ function selectVariant(urn: string | null | undefined) {
+ selectedVariantUrn.value = urn ?? null
+ }
+
+ // The `?variant=` highlight can change without the CAID changing (e.g. arriving from a redirect).
+ watch(highlightUrn, (urn) => {
+ if (urn && variants.value.some((m) => m.variantUrn === urn)) {
+ selectedVariantUrn.value = urn
+ }
+ })
+
+ // When filters hide the currently selected measurement, fall back to the first visible one.
+ watch(filteredVariants, (visible) => {
+ if (selectedVariantUrn.value && !visible.some((m) => m.variantUrn === selectedVariantUrn.value)) {
+ selectedVariantUrn.value = visible[0]?.variantUrn ?? null
+ }
+ })
+
+ watch(selectedVariantUrn, async (newUrn) => {
+ if (!newUrn) return
+ // Load the display essentials (detail + score set) first, then the heavier lean score distribution.
+ // This allows the majority of data to render while we wait for any visualizations to load.
+ await cache.loadDetail(newUrn)
+ cache.loadScores(scoreSetUrnOf(newUrn))
+ })
+
+ // A new score set invalidates any manual calibration pick (a stale URN wouldn't match the new set).
+ watch(selectedScoreSetUrn, () => {
+ selectedCalibration.value = null
+ })
+
+ // Default the calibration from the score set as soon as it loads. Only fills an empty selection,
+ // so a user's manual pick via the histogram dropdown is always retained.
+ watch(
+ selectedScoreSet,
+ (scoreSet) => {
+ if (!selectedCalibration.value) {
+ selectedCalibration.value = chooseDefaultCalibration(scoreSet?.scoreCalibrations)?.urn ?? null
+ }
+ },
+ {immediate: true}
+ )
+
+ return {
+ selectedVariantUrn,
+ selectVariant,
+ selectedVariant,
+ selectedLoading,
+ selectedVariantDetail,
+ selectedVariantName,
+ selectedClingenAlleleId,
+ selectedScoreSet,
+ selectedScoreSetUrn,
+ scores,
+ variantScoreRow,
+ selectedVariantScore,
+ selectedCalibration,
+ selectedCalibrationObject,
+ calibrationResolution
+ }
+}
diff --git a/src/composables/use-score-set-downloads.test.ts b/src/composables/use-score-set-downloads.test.ts
index a077faa8..991790d2 100644
--- a/src/composables/use-score-set-downloads.test.ts
+++ b/src/composables/use-score-set-downloads.test.ts
@@ -5,17 +5,18 @@ import {useScoreSetDownloads} from './use-score-set-downloads'
const downloadScoreSetFile = vi.fn()
const downloadScoreSetVariantData = vi.fn()
-const downloadMappedVariants = vi.fn()
vi.mock('@/api/mavedb', () => ({
downloadScoreSetFile: (...args: unknown[]) => downloadScoreSetFile(...args),
- downloadScoreSetVariantData: (...args: unknown[]) => downloadScoreSetVariantData(...args),
- downloadMappedVariants: (...args: unknown[]) => downloadMappedVariants(...args)
+ downloadScoreSetVariantData: (...args: unknown[]) => downloadScoreSetVariantData(...args)
}))
// The real one reaches for `document`; these tests run in the node environment.
vi.mock('@/lib/downloads', () => ({triggerDownload: vi.fn()}))
+const authHeader = vi.fn((): Record => ({}))
+vi.mock('@/lib/auth', () => ({authHeader: () => authHeader()}))
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const SCORE_SET = ref({urn: 'urn:mavedb:00000001-a-1'} as any)
@@ -29,7 +30,6 @@ function recordProgress(source: Ref): {values: (number | null)[];
beforeEach(() => {
downloadScoreSetFile.mockReset()
downloadScoreSetVariantData.mockReset()
- downloadMappedVariants.mockReset()
})
describe('useScoreSetDownloads download indicator', () => {
@@ -108,20 +108,6 @@ describe('useScoreSetDownloads download indicator', () => {
expect(fileDownloadLabel.value).toBeNull()
})
- it('covers the mapped-variants download too', async () => {
- let release: (data: unknown) => void = () => {}
- downloadMappedVariants.mockReturnValueOnce(new Promise((resolve) => (release = resolve)))
- const {downloadMappedVariantsFile, fileDownloadLabel} = useScoreSetDownloads({scoreSet: SCORE_SET})
-
- const pending = downloadMappedVariantsFile()
- expect(fileDownloadLabel.value).toBe('Mapped variants')
-
- release([])
- await pending
-
- expect(fileDownloadLabel.value).toBeNull()
- })
-
it('does nothing without a score set', async () => {
const {downloadFile, fileDownloadInProgress} = useScoreSetDownloads({scoreSet: ref(null)})
@@ -285,3 +271,75 @@ describe('useScoreSetDownloads annotation streaming shares the indicator', () =>
releaseRead()
})
})
+
+describe('useScoreSetDownloads variant-details streaming', () => {
+ /** Serve a one-record NDJSON body, capturing the URL the stream was opened against. */
+ function mockStream() {
+ const encoder = new TextEncoder()
+ let index = 0
+ const chunks = ['{"variantUrn":"urn:1"}\n']
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ headers: {get: (name: string) => (name === 'X-Total-Count' ? '1' : null)},
+ body: {
+ getReader: () => ({
+ read: async () =>
+ index < chunks.length ? {done: false, value: encoder.encode(chunks[index++])} : {done: true}
+ })
+ }
+ })
+ vi.stubGlobal('fetch', fetchMock)
+ return fetchMock
+ }
+
+ beforeEach(() => {
+ vi.stubGlobal('Blob', class {})
+ vi.stubGlobal('URL', {createObjectURL: () => 'blob:stub', revokeObjectURL: () => {}})
+ vi.stubGlobal('document', {createElement: () => ({click: () => {}})})
+ })
+
+ // Guards the #743 replacement: /mapped-variants now answers 410, so a resolution that restores it
+ // would ship a permanently failing button. Pin the sub-path rather than trusting the label.
+ it('streams from /variant-details, not the retired /mapped-variants', async () => {
+ const fetchMock = mockStream()
+ const downloads = useScoreSetDownloads({scoreSet: SCORE_SET})
+
+ await downloads.streamVariantDetails()
+
+ const url = fetchMock.mock.calls[0][0] as string
+ expect(url).toContain('/score-sets/urn:mavedb:00000001-a-1/variant-details')
+ expect(url).not.toContain('mapped-variants')
+ })
+
+ it('sends the signed-in user\'s credentials, which fetch does not get from the axios interceptor', async () => {
+ authHeader.mockReturnValueOnce({Authorization: 'Bearer token', 'X-Active-Roles': ['admin', 'mapper']})
+ const fetchMock = mockStream()
+ const downloads = useScoreSetDownloads({scoreSet: SCORE_SET})
+
+ await downloads.streamVariantDetails()
+
+ expect(fetchMock.mock.calls[0][1].headers).toMatchObject({
+ Authorization: 'Bearer token',
+ 'X-Active-Roles': 'admin,mapper'
+ })
+ })
+
+ it('sends no credentials when signed out', async () => {
+ const fetchMock = mockStream()
+ const downloads = useScoreSetDownloads({scoreSet: SCORE_SET})
+
+ await downloads.streamVariantDetails()
+
+ expect(fetchMock.mock.calls[0][1].headers).not.toHaveProperty('Authorization')
+ })
+
+ it('shares the one download indicator', async () => {
+ mockStream()
+ const downloads = useScoreSetDownloads({scoreSet: SCORE_SET})
+
+ const pending = downloads.streamVariantDetails()
+ expect(downloads.fileDownloadLabel.value).toBe('Variant details')
+ await pending
+ expect(downloads.fileDownloadLabel.value).toBeNull()
+ })
+})
diff --git a/src/composables/use-score-set-downloads.ts b/src/composables/use-score-set-downloads.ts
index 51b024f6..4a2673ee 100644
--- a/src/composables/use-score-set-downloads.ts
+++ b/src/composables/use-score-set-downloads.ts
@@ -1,9 +1,9 @@
import {computed, ref, type Ref} from 'vue'
+import {downloadScoreSetFile, downloadScoreSetVariantData} from '@/api/mavedb'
import type {CsvExtraOption} from '@/composables/use-csv-namespaces'
-
-import {downloadScoreSetFile, downloadScoreSetVariantData, downloadMappedVariants} from '@/api/mavedb'
import config from '@/config'
+import {authHeader} from '@/lib/auth'
import {triggerDownload} from '@/lib/downloads'
import type {components} from '@/schema/openapi'
@@ -11,7 +11,7 @@ type ScoreSet = components['schemas']['ScoreSet']
export const TEXT_COLUMNS = ['hgvs_nt', 'hgvs_splice', 'hgvs_pro']
-/** What a completed annotation stream contained, tallied as it arrived. */
+/** What a completed NDJSON stream contained, tallied as it arrived. */
export interface AnnotationStreamOutcome {
/** Records received. Equals `X-Total-Count` for a complete stream — the server emits one per variant. */
received: number
@@ -53,9 +53,9 @@ export function useScoreSetDownloads({scoreSet}: UseScoreSetDownloadsOptions) {
/**
* Percent complete, or null when the download cannot report progress.
*
- * Only the VA-Spec streams can: they carry `X-Total-Count` and emit one NDJSON record per line, so
- * records can be tallied as they arrive. A CSV arrives as a single gzipped body whose `Content-Length`
- * is the *compressed* size, which browsers compare against decompressed bytes received, so no usable
+ * Only the NDJSON streams can: they carry `X-Total-Count` and emit one record per line, so records can
+ * be tallied as they arrive. A CSV arrives as a single gzipped body whose `Content-Length` is the
+ * *compressed* size, which browsers compare against decompressed bytes received, so no usable
* percentage exists — and most of that wait is the server building the file before any byte is sent.
*/
const fileDownloadProgress = ref(null)
@@ -104,14 +104,6 @@ export function useScoreSetDownloads({scoreSet}: UseScoreSetDownloadsOptions) {
})
}
- async function downloadMappedVariantsFile() {
- if (!scoreSet.value) return
- await withIndicator('Mapped variants', async () => {
- const data = await downloadMappedVariants(scoreSet.value!.urn)
- triggerDownload(JSON.stringify(data), `${scoreSet.value!.urn}_mapped_variants.json`, 'text/json')
- })
- }
-
function downloadMetadata() {
if (!scoreSet.value) return
const metadata = JSON.stringify(scoreSet.value.extraMetadata)
@@ -126,20 +118,48 @@ export function useScoreSetDownloads({scoreSet}: UseScoreSetDownloadsOptions) {
}
}
+ /**
+ * Stream the per-variant record set, one `VariantDetail` per line.
+ *
+ * Replaces the retired `/mapped-variants` download (#743): that endpoint now answers 410, and this
+ * carries strictly more — the VRS pair, Cat-VRS membership, and the annotation layer.
+ *
+ * Resolves to what the stream contained, so the caller can report partial failures.
+ */
+ async function streamVariantDetails(label = 'Variant details') {
+ const urn = scoreSet.value?.urn
+ if (!urn) return
+ return await withIndicator(label, () =>
+ streamNdjsonInto(urn, 'variant-details', `${urn}_variant_details.ndjson`)
+ )
+ }
+
/** Resolves to what the stream contained, so the caller can report partial failures. */
async function streamVariantAnnotations(annotationType: string, label = 'annotations') {
const urn = scoreSet.value?.urn
if (!urn) return
- return await withIndicator(label, () => streamAnnotationsInto(urn, annotationType))
+ return await withIndicator(label, () =>
+ streamNdjsonInto(
+ urn,
+ `annotated-variants/${annotationType}`,
+ `${urn}_annotated_variants_${annotationType}.ndjson`
+ )
+ )
}
- async function streamAnnotationsInto(urn: string, annotationType: string) {
+ async function streamNdjsonInto(urn: string, subPath: string, filename: string) {
streamController.value = new AbortController()
try {
- const response = await fetch(`${config.apiBaseUrl}/score-sets/${urn}/annotated-variants/${annotationType}`, {
+ // fetch bypasses the axios interceptor that authenticates API calls, so send the credentials here.
+ // The API splits X-Active-Roles on bare commas.
+ const {Authorization, 'X-Active-Roles': activeRoles} = authHeader()
+ const response = await fetch(`${config.apiBaseUrl}/score-sets/${urn}/${subPath}`, {
signal: streamController.value.signal,
- headers: {Accept: 'application/x-ndjson'}
+ headers: {
+ Accept: 'application/x-ndjson',
+ ...(Authorization ? {Authorization, 'X-Active-Roles': (activeRoles ?? []).join(',')} : {})
+ }
})
if (!response.ok) {
@@ -196,7 +216,7 @@ export function useScoreSetDownloads({scoreSet}: UseScoreSetDownloadsOptions) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
- anchor.download = `${urn}_annotated_variants_${annotationType}.ndjson`
+ anchor.download = filename
anchor.click()
URL.revokeObjectURL(url)
@@ -223,8 +243,8 @@ export function useScoreSetDownloads({scoreSet}: UseScoreSetDownloadsOptions) {
// Methods
downloadFile,
downloadMultipleData,
- downloadMappedVariantsFile,
downloadMetadata,
+ streamVariantDetails,
streamVariantAnnotations,
abortStream
}
diff --git a/src/composables/use-variant-coordinates.test.ts b/src/composables/use-variant-coordinates.test.ts
new file mode 100644
index 00000000..bb1225c3
--- /dev/null
+++ b/src/composables/use-variant-coordinates.test.ts
@@ -0,0 +1,217 @@
+import {describe, expect, it} from 'vitest'
+
+import {useVariantCoordinates} from '@/composables/use-variant-coordinates'
+import type {HgvsField, LeanVariant} from '@/lib/variants'
+
+const {
+ coordinateFor,
+ getHgvsNt,
+ getHgvsPro,
+ labelForVariant,
+ levelAvailable,
+ sequenceTypeOptions,
+ resolveLevel
+} = useVariantCoordinates()
+
+function field(hgvs: string, position?: number, ref?: string, alt?: string): HgvsField {
+ return {hgvs, position, ref, alt}
+}
+
+function variant(overrides: Partial): LeanVariant {
+ return {variantUrn: 'urn:mavedb:00000001-a-1#1', ...overrides} as LeanVariant
+}
+
+// A coding assay: measured at the cdna level, projected up to protein and down to genomic.
+const dnaAssay = variant({
+ variantUrn: 'urn:dna#1',
+ hgvsNt: field('c.6C>T', 6, 'C', 'T'),
+ hgvsPro: field('p.Leu2Phe', 2, 'Leu', 'Phe'),
+ assayLevel: 'cdna',
+ mapped: {
+ genomic: field('NC_x:g.100C>T', 100, 'C', 'T'),
+ cdna: field('NM_x:c.6C>T', 6, 'C', 'T'),
+ protein: field('NP_x:p.Leu2Phe', 2, 'Leu', 'Phe')
+ }
+})
+
+// A genomic assay: measured at the genomic level; cdna is still present as the coding search key.
+const genomicAssay = variant({
+ variantUrn: 'urn:g#1',
+ hgvsNt: field('g.100C>T', 100, 'C', 'T'),
+ assayLevel: 'genomic',
+ mapped: {
+ genomic: field('NC_x:g.100C>T', 100, 'C', 'T'),
+ cdna: field('NM_x:c.6C>T', 6, 'C', 'T'),
+ protein: field('NP_x:p.Leu2Phe', 2, 'Leu', 'Phe')
+ }
+})
+
+// A protein assay: measured at the protein level; the c/g fan-out is ambiguous, so only protein is
+// filled (mavedb-api#784).
+const proteinAssay = variant({
+ variantUrn: 'urn:pro#1',
+ hgvsPro: field('p.Leu6Gly', 6, 'Leu', 'Gly'),
+ assayLevel: 'protein',
+ mapped: {protein: field('NP_x:p.Leu6Gly', 6, 'Leu', 'Gly')}
+})
+
+describe('coordinateFor — (level, frame) resolution', () => {
+ it('submitted frame: cdna and genomic both alias hgvsNt regardless of assayLevel', () => {
+ // The cdna/genomic distinction only exists in reference frame; submitted frame has one NT field.
+ expect(coordinateFor(dnaAssay, 'cdna', 'submitted')).toBe(dnaAssay.hgvsNt)
+ expect(coordinateFor(dnaAssay, 'genomic', 'submitted')).toBe(dnaAssay.hgvsNt)
+ expect(coordinateFor(genomicAssay, 'cdna', 'submitted')).toBe(genomicAssay.hgvsNt)
+ expect(coordinateFor(genomicAssay, 'genomic', 'submitted')).toBe(genomicAssay.hgvsNt)
+ })
+
+ it('submitted frame: protein reads hgvsPro', () => {
+ expect(coordinateFor(dnaAssay, 'protein', 'submitted')).toBe(dnaAssay.hgvsPro)
+ expect(coordinateFor(proteinAssay, 'protein', 'submitted')).toBe(proteinAssay.hgvsPro)
+ })
+
+ it('reference frame: each level routes directly to its MappedTriple slot', () => {
+ expect(coordinateFor(dnaAssay, 'cdna', 'reference')).toBe(dnaAssay.mapped!.cdna)
+ expect(coordinateFor(dnaAssay, 'genomic', 'reference')).toBe(dnaAssay.mapped!.genomic)
+ expect(coordinateFor(dnaAssay, 'protein', 'reference')).toBe(dnaAssay.mapped!.protein)
+ expect(coordinateFor(genomicAssay, 'genomic', 'reference')).toBe(genomicAssay.mapped!.genomic)
+ expect(coordinateFor(genomicAssay, 'cdna', 'reference')).toBe(genomicAssay.mapped!.cdna)
+ expect(coordinateFor(proteinAssay, 'protein', 'reference')).toBe(proteinAssay.mapped!.protein)
+ })
+
+ it('reference cdna/genomic are null for a protein assay — never fabricates a coding coordinate (#784)', () => {
+ expect(coordinateFor(proteinAssay, 'cdna', 'reference')).toBeNull()
+ expect(coordinateFor(proteinAssay, 'genomic', 'reference')).toBeNull()
+ })
+
+ it('returns null for an absent slot', () => {
+ expect(coordinateFor(proteinAssay, 'cdna', 'submitted')).toBeNull()
+ expect(coordinateFor(variant({}), 'protein', 'submitted')).toBeNull()
+ expect(coordinateFor(variant({}), 'cdna', 'reference')).toBeNull()
+ })
+})
+
+describe('getHgvsNt / getHgvsPro — string convenience over coordinateFor', () => {
+ it('getHgvsNt returns hgvsNt directly in submitted frame, coding-preferred in reference frame', () => {
+ expect(getHgvsNt(dnaAssay, 'submitted')).toBe('c.6C>T')
+ expect(getHgvsNt(genomicAssay, 'submitted')).toBe('g.100C>T')
+ expect(getHgvsNt(dnaAssay, 'reference')).toBe('NM_x:c.6C>T')
+ // Coding-preferred: a genomic-measured variant surfaces its NM_:c. coding key, not the NC_:g. one.
+ expect(getHgvsNt(genomicAssay, 'reference')).toBe('NM_x:c.6C>T')
+ expect(getHgvsNt(proteinAssay, 'reference')).toBeUndefined()
+ })
+
+ it('getHgvsNt falls back to genomic when a genomic assay has no coding projection', () => {
+ // Deep intronic / intergenic in a genomic set: no cdna slot, so NC_:g. is the sole nucleotide identity.
+ const genomicOnly = variant({
+ variantUrn: 'urn:g-only#1',
+ assayLevel: 'genomic',
+ mapped: {genomic: field('NC_x:g.100C>T', 100, 'C', 'T')}
+ })
+ expect(getHgvsNt(genomicOnly, 'reference')).toBe('NC_x:g.100C>T')
+ })
+
+ it('getHgvsPro returns the resolved protein hgvs string or undefined', () => {
+ expect(getHgvsPro(proteinAssay, 'submitted')).toBe('p.Leu6Gly')
+ expect(getHgvsPro(dnaAssay, 'reference')).toBe('NP_x:p.Leu2Phe')
+ })
+})
+
+describe('labelForVariant — frame coords → submitted HGVS → URN', () => {
+ it('prefers protein, in the requested frame', () => {
+ expect(labelForVariant(dnaAssay, 'submitted')).toBe('p.Leu2Phe')
+ expect(labelForVariant(dnaAssay, 'reference')).toBe('NP_x:p.Leu2Phe')
+ })
+
+ it('falls back to nucleotide when protein is absent', () => {
+ const ntOnly = variant({variantUrn: 'urn:nt', hgvsNt: field('c.9A>G', 9, 'A', 'G')})
+ expect(labelForVariant(ntOnly, 'submitted')).toBe('c.9A>G')
+ })
+
+ it('in the reference frame, an unmapped variant falls back to its submitted HGVS, not the URN', () => {
+ const unmappedIntronic = variant({variantUrn: 'urn:intron', hgvsNt: field('c.122-6T>A')})
+ expect(labelForVariant(unmappedIntronic, 'reference')).toBe('c.122-6T>A')
+ })
+
+ it('prefers submitted protein over submitted nucleotide in the fallback', () => {
+ const unmapped = variant({
+ variantUrn: 'urn:u',
+ hgvsNt: field('c.6C>T', 6, 'C', 'T'),
+ hgvsPro: field('p.Leu2Phe', 2, 'Leu', 'Phe')
+ })
+ expect(labelForVariant(unmapped, 'reference')).toBe('p.Leu2Phe')
+ })
+
+ it('falls back to splice, then to the URN when no other HGVS exists', () => {
+ const spliceOnly = variant({variantUrn: 'urn:sp', hgvsSplice: field('c.1-2A>G')})
+ expect(labelForVariant(spliceOnly, 'submitted')).toBe('c.1-2A>G')
+ expect(labelForVariant(spliceOnly, 'reference')).toBe('c.1-2A>G')
+ expect(labelForVariant(variant({variantUrn: 'urn:bare'}), 'submitted')).toBe('urn:bare')
+ expect(labelForVariant(variant({variantUrn: 'urn:bare'}), 'reference')).toBe('urn:bare')
+ })
+})
+
+describe('levelAvailable / sequenceTypeOptions — availability per frame', () => {
+ it('in submitted frame levelAvailable returns true for both cdna and genomic when hgvsNt is present', () => {
+ // cdna and genomic alias hgvsNt in submitted frame — both are technically available.
+ // sequenceTypeOptions collapses them into one option using assayLevel as a label hint.
+ expect(levelAvailable([dnaAssay], 'cdna', 'submitted')).toBe(true)
+ expect(levelAvailable([dnaAssay], 'genomic', 'submitted')).toBe(true)
+ expect(levelAvailable([genomicAssay], 'cdna', 'submitted')).toBe(true)
+ expect(levelAvailable([genomicAssay], 'genomic', 'submitted')).toBe(true)
+ })
+
+ it('sequenceTypeOptions offers one NT option in submitted frame labelled Nucleotide, value keyed by assayLevel', () => {
+ expect(sequenceTypeOptions([dnaAssay], 'submitted')).toEqual([
+ {title: 'Nucleotide', value: 'cdna'},
+ {title: 'Amino acid', value: 'protein'}
+ ])
+ expect(sequenceTypeOptions([genomicAssay], 'submitted')).toEqual([{title: 'Nucleotide', value: 'genomic'}])
+ })
+
+ it('a cdna assay with all mapped slots offers cdna, genomic, protein in reference frame', () => {
+ expect(sequenceTypeOptions([dnaAssay], 'reference')).toEqual([
+ {title: 'cDNA', value: 'cdna'},
+ {title: 'Genomic', value: 'genomic'},
+ {title: 'Amino acid', value: 'protein'}
+ ])
+ })
+
+ it('a genomic assay offers cdna, genomic, protein in reference frame', () => {
+ expect(sequenceTypeOptions([genomicAssay], 'reference')).toEqual([
+ {title: 'cDNA', value: 'cdna'},
+ {title: 'Genomic', value: 'genomic'},
+ {title: 'Amino acid', value: 'protein'}
+ ])
+ })
+
+ it('a protein assay never offers cdna or genomic — in either frame (#784)', () => {
+ expect(sequenceTypeOptions([proteinAssay], 'submitted')).toEqual([{title: 'Amino acid', value: 'protein'}])
+ expect(sequenceTypeOptions([proteinAssay], 'reference')).toEqual([{title: 'Amino acid', value: 'protein'}])
+ expect(levelAvailable([proteinAssay], 'cdna', 'reference')).toBe(false)
+ expect(levelAvailable([proteinAssay], 'genomic', 'reference')).toBe(false)
+ })
+
+ it('availability is a set-wide OR across variants', () => {
+ expect(sequenceTypeOptions([dnaAssay, proteinAssay], 'reference')).toEqual([
+ {title: 'cDNA', value: 'cdna'},
+ {title: 'Genomic', value: 'genomic'},
+ {title: 'Amino acid', value: 'protein'}
+ ])
+ })
+})
+
+describe('resolveLevel — explicit frame→level coupling', () => {
+ it('keeps the desired level when available', () => {
+ expect(resolveLevel([dnaAssay], 'protein', 'reference')).toBe('protein')
+ expect(resolveLevel([dnaAssay], 'cdna', 'reference')).toBe('cdna')
+ expect(resolveLevel([dnaAssay], 'genomic', 'reference')).toBe('genomic')
+ })
+
+ it('falls back deterministically when the desired level is stranded', () => {
+ expect(resolveLevel([proteinAssay], 'cdna', 'reference')).toBe('protein')
+ })
+
+ it('returns null when no level is available', () => {
+ expect(resolveLevel([variant({variantUrn: 'urn:bare'})], 'protein', 'submitted')).toBeNull()
+ })
+})
diff --git a/src/composables/use-variant-coordinates.ts b/src/composables/use-variant-coordinates.ts
index e6e18cd7..b6221417 100644
--- a/src/composables/use-variant-coordinates.ts
+++ b/src/composables/use-variant-coordinates.ts
@@ -1,84 +1,196 @@
-import {variantNotNullOrNA, preferredVariantLabel, type SimpleMaveVariant} from '@/lib/mave-hgvs'
-import type {Variant} from '@/lib/variants'
+import type {KeySection} from '@/composables/use-key-drawer'
+import type {components} from '@/schema/openapi'
+import type {HgvsField, LeanVariant} from '@/lib/variants'
+
+/** The sequence level a coordinate is expressed in — the canonical alias of the backend enum. Also the
+ * value space for a measurement's assay level (see `@/lib/measurement-types`). */
+export type SequenceLevel = components['schemas']['SequenceLevel']
+
+/** The coordinate frame: `submitted` = submitted/target numbering, `reference` = reference numbering. */
+export type CoordinateFrame = 'submitted' | 'reference'
+
+/** Key-drawer glossary for the coordinate-frame axis this composable resolves. */
+export const COORDINATE_FRAME_KEY_SECTION: KeySection = {
+ id: 'frame',
+ title: 'Coordinate frame',
+ terms: [
+ {
+ label: 'Submitted',
+ definition: 'Coordinates exactly as the depositor submitted them, relative to the target sequence.'
+ },
+ {
+ label: 'Reference',
+ definition: "Coordinates re-expressed against a standard reference sequence by MaveDB's mapping pipeline."
+ }
+ ]
+}
/**
- * Stateless composable for resolving variant HGVS coordinates based on
- * the current display mode (raw vs mapped). Shared between ScoreSetView
- * (variant search, labels, sequence type detection) and ScoreSetHeatmap.
+ * Stateless resolution of a variant's HGVS coordinate across two orthogonal axes — sequence
+ * **level** (cdna / genomic / protein) and **frame** (submitted ↔ reference).
+ *
+ * `coordinateFor` is the single source of truth: every downstream derivation (heatmap x/y,
+ * axis availability, labels, tooltips) resolves through it, so the (level, frame) → coordinate
+ * mapping lives in exactly one place. The frame axis is load-bearing — submitted and reference are
+ * genuinely different coordinate systems, not the same grid with different captions.
+ *
+ * Shared between ScoreSetView (search, labels, level options) and ScoreSetHeatmap (plotting).
*/
export function useVariantCoordinates() {
/**
- * Get the best nucleotide HGVS string for a variant.
- * In mapped mode, prefers post_mapped_hgvs_c when available.
+ * Resolve the HGVS coordinate for a variant at a given level and frame, or `null` when that
+ * cell does not exist for the variant.
+ *
+ * In the **reference** frame each level routes directly to its `MappedTriple` slot:
+ *
+ * | level | reference slot |
+ * | ------- | ----------------- |
+ * | cdna | `mapped.cdna` |
+ * | genomic | `mapped.genomic` |
+ * | protein | `mapped.protein` |
+ *
+ * In the **submitted** frame `cdna` and `genomic` both alias `hgvsNt` — the depositor submitted one
+ * nucleotide string and the schema has a single field for it. The cdna/genomic distinction is a
+ * post-mapping conclusion, since submitted HGVS strings only have meaning relative to the submitted
+ * sequence. Both levels therefore return `hgvsNt` and `levelAvailable` will report both as true whenever
+ * `hgvsNt` is present. `sequenceTypeOptions` handles the display concern of offering only one NT option
+ * in the submitted frame.
*/
- function getHgvsNt(variant: Variant, useMapped: boolean): string | undefined {
- if (useMapped && variantNotNullOrNA(variant.mavedb?.post_mapped_hgvs_c)) {
- return variant.mavedb!.post_mapped_hgvs_c!
+ function coordinateFor(variant: LeanVariant, level: SequenceLevel, frame: CoordinateFrame): HgvsField | null {
+ if (frame === 'submitted') {
+ if (level === 'protein') return variant.hgvsPro ?? null
+ return variant.hgvsNt ?? null
}
- return variant.hgvs_nt
+ // reference: direct slot lookup — no assayLevel indirection needed.
+ return variant.mapped?.[level] ?? null
}
/**
- * Get the best protein HGVS string for a variant.
- * In mapped mode, prefers translated_hgvs_p or post_mapped_hgvs_p.
- * In raw mode, falls back through hgvs_pro → translated_hgvs_p.
+ * The canonical nucleotide HGVS string for a variant in the given frame, if any — the single
+ * nucleotide coordinate a compact surface (label pair, tooltip note, search chip) should show.
+ *
+ * In the submitted frame reads `hgvsNt` directly — no level discrimination, since the submitted string
+ * is a single field. In the reference frame the ordering is prescriptive: **coding (`cdna`) preferred,
+ * genomic fallback.** The coding `NM_:c.` string is the natural pair of the protein `NP_:p.` change
+ * (same transcript, same frame, the conventional `c. (p.)` citation), so a genomic-*measured* variant
+ * surfaces its coding coordinate here rather than the `NC_:g.` one. It becomes the string only when
+ * there is no coding projection.
+ *
+ * The plotted-axis coordinate is a different concern: when a surface has a user-selected level (the
+ * heatmap axis) it should read `coordinateFor(variant, level, frame)` directly, not this.
+ *
+ * Returns `undefined` for protein assays or unmapped variants in the reference frame.
*/
- function getHgvsPro(variant: Variant, useMapped: boolean): string | undefined {
- if (useMapped) {
- if (variantNotNullOrNA(variant.translated_hgvs_p)) return variant.translated_hgvs_p
- if (variantNotNullOrNA(variant.mavedb?.post_mapped_hgvs_p)) return variant.mavedb!.post_mapped_hgvs_p!
- }
- if (variantNotNullOrNA(variant.hgvs_pro)) return variant.hgvs_pro
- if (variantNotNullOrNA(variant.translated_hgvs_p)) return variant.translated_hgvs_p
- return undefined
+ function getHgvsNt(variant: LeanVariant, frame: CoordinateFrame): string | undefined {
+ if (frame === 'submitted') return variant.hgvsNt?.hgvs
+ return (variant.mapped?.cdna ?? variant.mapped?.genomic)?.hgvs
+ }
+
+ /** The protein HGVS string for a variant in the given frame, if any. */
+ function getHgvsPro(variant: LeanVariant, frame: CoordinateFrame): string | undefined {
+ return coordinateFor(variant, 'protein', frame)?.hgvs
}
/**
- * Get the preferred display label for a variant, respecting coordinate mode.
- * Prefers protein > nucleotide > splice > accession.
+ * Preferred display label in the given frame, following the prescriptive identity order
+ * protein > coding > genomic (the last two via `getHgvsNt`), then submitted-string fallbacks:
+ * frame protein → frame nucleotide (coding-preferred) → submitted protein → submitted nucleotide → submitted splice → URN.
+ *
+ * The reference frame has no coordinate for an unmapped variant, so before giving up to the bare URN we
+ * fall back to the variant's submitted (target-frame) HGVS: an unmapped intronic variant still carries
+ * its `c.122-6T>A`, which is far more informative than the URN. In the submitted frame the first two
+ * slots already are the submitted strings, so the fallback is inert there. The URN surfaces only when
+ * the variant carries no HGVS at all.
*/
- function labelForVariant(variant: Variant, useMapped: boolean): {mavedb_label: string} {
- const pro = getHgvsPro(variant, useMapped)
- if (variantNotNullOrNA(pro)) return {mavedb_label: pro!}
-
- const nt = getHgvsNt(variant, useMapped)
- if (variantNotNullOrNA(nt)) return {mavedb_label: nt!}
-
- if (variantNotNullOrNA(variant.hgvs_splice)) return {mavedb_label: variant.hgvs_splice!}
+ function labelForVariant(variant: LeanVariant, frame: CoordinateFrame): string {
+ return (
+ coordinateFor(variant, 'protein', frame)?.hgvs ??
+ getHgvsNt(variant, frame) ??
+ variant.hgvsPro?.hgvs ??
+ variant.hgvsNt?.hgvs ??
+ variant.hgvsSplice?.hgvs ??
+ variant.variantUrn
+ )
+ }
- return preferredVariantLabel(variant as SimpleMaveVariant)
+ /** Whether any variant resolves a coordinate at the given level and frame. */
+ function levelAvailable(variants: LeanVariant[], level: SequenceLevel, frame: CoordinateFrame): boolean {
+ return variants.some((v) => coordinateFor(v, level, frame) != null)
}
+ const LEVEL_LABELS: Record = {cdna: 'cDNA', genomic: 'Genomic', protein: 'Amino acid'}
+ const LEVEL_ORDER: SequenceLevel[] = ['cdna', 'genomic', 'protein']
+
/**
- * Check whether any variants have DNA (nucleotide) HGVS data available.
+ * The level options to offer for the given frame, in display order.
+ *
+ * In the **reference** frame returns whichever of cDNA / Genomic / Protein have data (up to all three
+ * for a nucleotide assay). In the **submitted** frame cdna and genomic both alias `hgvsNt`, so the
+ * distinction is meaningless — the submitted string is target-relative and its level is only
+ * determined after mapping. A single "Nucleotide" option is returned instead, with the `value`
+ * keyed to `assayLevel` so that a frame switch routes to the right reference slot.
*/
- function hasDnaVariants(variants: Variant[], useMapped: boolean): boolean {
- return variants.some((v) => variantNotNullOrNA(getHgvsNt(v, useMapped)))
+ function sequenceTypeOptions(
+ variants: LeanVariant[],
+ frame: CoordinateFrame
+ ): Array<{title: string; value: SequenceLevel}> {
+ if (frame === 'submitted') {
+ const options: Array<{title: string; value: SequenceLevel}> = []
+
+ if (variants.some((v) => v.hgvsNt != null)) {
+ const ntLevel =
+ (variants.find((v) => v.assayLevel === 'cdna' || v.assayLevel === 'genomic')?.assayLevel as SequenceLevel) ??
+ 'cdna'
+ options.push({title: 'Nucleotide', value: ntLevel})
+ }
+
+ if (variants.some((v) => v.hgvsPro != null)) options.push({title: 'Amino acid', value: 'protein'})
+ return options
+ }
+
+ return LEVEL_ORDER.filter((level) => levelAvailable(variants, level, frame)).map((level) => ({
+ title: LEVEL_LABELS[level],
+ value: level
+ }))
}
/**
- * Check whether any variants have protein HGVS data available.
+ * Resolve which level to actually display: keep `desiredLevel` if it is available in this
+ * frame, otherwise fall back to the first available level, or `null` if none are. Makes the
+ * frame→level coupling explicit — when a frame flip strands the current level, the fallback is
+ * deterministic rather than dependent on a watcher firing.
*/
- function hasProteinVariants(variants: Variant[], useMapped: boolean): boolean {
- return variants.some((v) => variantNotNullOrNA(getHgvsPro(v, useMapped)))
+ function resolveLevel(
+ variants: LeanVariant[],
+ desiredLevel: SequenceLevel,
+ frame: CoordinateFrame
+ ): SequenceLevel | null {
+ const available = sequenceTypeOptions(variants, frame).map((option) => option.value)
+ if (available.includes(desiredLevel)) return desiredLevel
+ return available[0] ?? null
}
/**
- * Build the sequence type options array for heatmap/display controls.
+ * Whether the mapping/annotation step produced no mapped allele for this variant — keyed on the
+ * authoritative allele digest, which is null exactly when nothing was mapped. In the reference frame
+ * such a variant has no reference coordinate, so its label falls back to the submitted (target-frame)
+ * HGVS. That fallback string can look mapped (accession-based target) or plainly unmapped
+ * (sequence-based target, bare `c.` coordinate), so callers gate a "could not be mapped" note on this
+ * being true and the frame being reference. The submitted string is shown by intent in the submitted
+ * frame.
*/
- function sequenceTypeOptions(variants: Variant[], useMapped: boolean): Array<{title: string; value: string}> {
- const options: Array<{title: string; value: string}> = []
- if (hasDnaVariants(variants, useMapped)) options.push({title: 'DNA', value: 'dna'})
- if (hasProteinVariants(variants, useMapped)) options.push({title: 'Protein', value: 'protein'})
- return options
+ function isUnmapped(variant: LeanVariant): boolean {
+ return variant.assayLevelDigest == null
}
return {
+ coordinateFor,
getHgvsNt,
getHgvsPro,
labelForVariant,
- hasDnaVariants,
- hasProteinVariants,
- sequenceTypeOptions
+ isUnmapped,
+ levelAvailable,
+ sequenceTypeOptions,
+ resolveLevel
}
}
diff --git a/src/composables/use-variant-lookup.ts b/src/composables/use-variant-lookup.ts
index 2e8589f0..f6d4b095 100644
--- a/src/composables/use-variant-lookup.ts
+++ b/src/composables/use-variant-lookup.ts
@@ -1,298 +1,238 @@
-import {computed, ref, shallowRef, watch, type ComputedRef, type Ref} from 'vue'
-
-import {
- downloadVariantCsv,
- getVariantAnnotation,
- getVariantDetail,
- getVariantPageScoreSetData,
- lookupVariantsByClingenId
-} from '@/api/mavedb/variants'
-import {useCalibrationResolution, type UseCalibrationResolutionReturn} from '@/composables/use-calibration-resolution'
+import pLimit from 'p-limit'
+import {computed, ref, type ComputedRef, type Ref, watch} from 'vue'
+
+import {downloadVariantCsv, getAlleleMeasurements, getVariantAnnotation} from '@/api/mavedb/variants'
import {useClingenAllele, type UseClingenAlleleReturn} from '@/composables/use-clingen-allele'
+import {scoreSetUrnOf, useMeasurementCache} from '@/composables/use-measurement-cache'
+import {useMeasurementSelection, type UseMeasurementSelectionReturn} from '@/composables/use-measurement-selection'
import type {CalibrationControlStatus} from '@/lib/calibration-controls'
-import {
- formatEvidenceCode,
- functionalClassificationContainsVariant,
- getClassificationOddsPath,
- getPrimaryCalibration
-} from '@/lib/calibrations'
import {triggerDownload} from '@/lib/downloads'
import {describeRequestError} from '@/lib/errors'
import {getExperimentKeyword} from '@/lib/experiments'
-import {gnomadFromVariantRow, type GnomadFrequency} from '@/lib/gnomad'
-import {parseScoreSetVariantData, type Variant} from '@/lib/variants'
-import type {MeasurementType} from '@/lib/measurement-types'
+import {assayLevelBucket} from '@/lib/measurement-types'
import type {components} from '@/schema/openapi'
-export type {MeasurementType}
+type AlleleMeasurement = components['schemas']['AlleleMeasurement']
-type ScoreCalibration = components['schemas']['ScoreCalibration']
-type ScoreSet = components['schemas']['ScoreSet']
-type VariantEffectMeasurementWithShortScoreSet = components['schemas']['VariantEffectMeasurementWithShortScoreSet']
-type VariantEffectMeasurementWithScoreSet = components['schemas']['VariantEffectMeasurementWithScoreSet']
-type FunctionalClassification =
- components['schemas']['mavedb__view_models__score_calibration__FunctionalClassification']
+export type {AlleleMeasurement}
-export type VariantEntry = {content: VariantEffectMeasurementWithShortScoreSet; type: MeasurementType}
+// Cap concurrent background prefetches so a large equivalence class doesn't fire a request storm on load.
+const PREFETCH_CONCURRENCY = 4
-export interface UseVariantLookupReturn {
+export interface UseVariantLookupReturn extends UseMeasurementSelectionReturn {
// ClinGen allele (delegated)
clingenAllele: UseClingenAlleleReturn
- // Variant list
- variants: Ref
+ // Measurements list
+ variants: Ref
variantsStatus: Ref<'NotLoaded' | 'Loading' | 'Loaded' | 'Error'>
fetchVariants: () => Promise
- // Filters
+ // Filters (by assayed level)
showNucleotide: Ref
showProtein: Ref
- showAssociatedNucleotide: Ref
+ includeSuperseded: Ref
+ // Content valid-time — null = current; reconstructs the molecular/annotation layer as of an instant.
+ asOf: Ref
nucleotideCount: ComputedRef
proteinCount: ComputedRef
- associatedNucleotideCount: ComputedRef
- filteredVariants: ComputedRef
-
- // Selection
- selectedVariantUrn: Ref
- selectVariant: (urn: string | null | undefined) => void
- selectedVariant: ComputedRef
- selectedVariantDetail: ComputedRef
- selectedVariantName: ComputedRef
- selectedClingenAlleleId: ComputedRef
-
- // Scores
- selectedScoreSet: ComputedRef
- selectedScoreSetUrn: ComputedRef
- scores: ComputedRef
- variantScoreRow: ComputedRef
- selectedVariantScore: ComputedRef
- selectedVariantGnomad: ComputedRef
-
- // Calibration
- selectedCalibration: Ref
- selectedCalibrationObject: ComputedRef
- calibrationResolution: UseCalibrationResolutionReturn
+ filteredVariants: ComputedRef
+
+ // The selected measurement's own status as one of the active calibration's controls, if it was used as one.
selectedVariantControlStatus: ComputedRef
- // Per-variant helpers
- getAbnormalOddsPath: (urn: string | null | undefined) => string | null
- getNormalOddsPath: (urn: string | null | undefined) => string | null
- getVariantClassification: (urn: string | null | undefined) => string | null
- getVariantEvidenceCode: (urn: string | null | undefined) => string | null
- getKeyword: (variantContent: VariantEffectMeasurementWithShortScoreSet, key: string) => string | null
+ // Digests of every allele that has a measurement of its own in the list — the page-wide notion of
+ // "measured", independent of which measurement is selected. Fills in as detail prefetches resolve.
+ measuredDigests: ComputedRef>
+
+ // Per-measurement helpers (for measurement cards)
+ getKeyword: (scoreSetUrn: string | null | undefined, key: string) => string | null
// Page-level
geneName: ComputedRef
uniqueAssayCount: ComputedRef
// Downloads
+ // Names the file being prepared, or null when idle. One indicator for every download here, which also
+ // serializes them: a second download is refused while one is in flight.
+ downloadInProgressLabel: Ref
fetchVariantAnnotations: (annotationType: string) => Promise
downloadVariantCsvFile: (namespaces?: string[]) => Promise
- /** What download is in flight, or null when idle. Indeterminate; see use-score-set-downloads. */
- downloadInProgressLabel: Ref
}
/**
- * Variant lookup composable for the VariantScreen.
+ * Variant lookup composable for the ClinGen-allele-centric VariantScreen.
*
- * Given a ClinGen allele ID, fetches all matching variant effect measurements
- * (nucleotide and protein level), manages measurement-type filters, and drives
- * the selected variant's detail panel, score distribution chart, and calibration
- * resolution.
+ * Given a ClinGen allele ID (a nucleotide `CA` or protein `PA`), fetches its cross-layer equivalence
+ * class of measurements from `GET /clingen-alleles/{caid}/measurements`, in the API's default order.
+ * Manages assay-level filters and drives the selected measurement's detail panel, score distribution
+ * chart, and calibration resolution.
+ *
+ * This is the orchestrating facade: it owns the measurements list, the query-axis controls, and the
+ * fetch flow, and composes the per-URN cache ({@link useMeasurementCache}), selection + its derivations
+ * ({@link useMeasurementSelection}), and allele metadata ({@link useClingenAllele}) into one flat return.
*
* Key behaviors:
- * - Variant details and score data are cached per-URN to avoid redundant fetches
- * when switching between measurement cards.
- * - When filters hide the currently selected variant, the selection auto-falls
- * back to the first visible measurement.
- * - Delegates to {@link useClingenAllele} for allele metadata and
- * {@link useCalibrationResolution} for classification resolution.
+ * - The measurements list order is authoritative; the default selection is simply the first entry (or
+ * the `?variant=` highlight when present).
+ * - Details, score sets, and score data are cached per-URN to avoid redundant fetches when switching
+ * between measurement cards.
+ * - Any query-axis change (anchor, superseded scope, content valid-time) refetches and clears the caches.
*
* Used by: VariantScreen.vue
*/
export function useVariantLookup(
clingenAlleleId: Ref,
- options?: {toast?: {add: (opts: {severity: string; summary: string; detail: string; life: number}) => void}}
+ options?: {
+ highlightUrn?: Ref
+ // Seeded from the URL so a shared `?include_superseded=`/`?as_of=` link loads in one fetch.
+ initialIncludeSuperseded?: boolean
+ initialAsOf?: string | null
+ toast?: {add: (opts: {severity: string; summary: string; detail: string; life: number}) => void}
+ }
): UseVariantLookupReturn {
- // ── Leaf composables ──────────────────────────────────────
const clingenAllele = useClingenAllele(clingenAlleleId)
+ const highlightUrn = options?.highlightUrn ?? ref(null)
- // ── Reactive state ────────────────────────────────────────
- const variants = ref([])
+ // ── Measurements list + query axes ────────────────────────
+ const variants = ref([])
const variantsStatus = ref<'NotLoaded' | 'Loading' | 'Loaded' | 'Error'>('NotLoaded')
- const selectedVariantUrn = ref(null)
- const downloadInProgressLabel = ref(null)
const showNucleotide = ref(true)
const showProtein = ref(true)
- const showAssociatedNucleotide = ref(true)
- const variantDetailCache = ref>({})
- const scoresCache = shallowRef>({})
- const selectedCalibration = ref(null)
-
- // ── Filters ───────────────────────────────────────────────
- const nucleotideCount = computed(() => variants.value.filter((v) => v.type === 'nucleotide').length)
- const proteinCount = computed(() => variants.value.filter((v) => v.type === 'protein').length)
- const associatedNucleotideCount = computed(
- () => variants.value.filter((v) => v.type === 'associatedNucleotide').length
+ const includeSuperseded = ref(options?.initialIncludeSuperseded ?? false)
+ const downloadInProgressLabel = ref(null)
+ const asOf = ref(options?.initialAsOf ?? null)
+
+ const prefetchLimit = pLimit(PREFETCH_CONCURRENCY)
+ // Bumped each fetch so stale-epoch prefetches (and a slower in-flight list response) bail instead of
+ // repopulating the just-cleared caches.
+ let queryEpoch = 0
+ // One-shot guard: honor an initial `?variant=` deep link that points at a superseded measurement exactly
+ // once (enable superseded so it resolves). After the first fetch, user toggles are always respected —
+ // toggling superseded off never re-enables itself just because the selected variant left the list.
+ let honoredInitialHighlight = false
+
+ // ── Filters (by assayed level) ────────────────────────────
+ const nucleotideCount = computed(
+ () => variants.value.filter((m) => assayLevelBucket(m.assayLevel) === 'nucleotide').length
+ )
+ const proteinCount = computed(
+ () => variants.value.filter((m) => assayLevelBucket(m.assayLevel) === 'amino acid').length
)
const filteredVariants = computed(() =>
- variants.value.filter((v) => {
- if (v.type === 'nucleotide') return showNucleotide.value
- if (v.type === 'protein') return showProtein.value
- if (v.type === 'associatedNucleotide') return showAssociatedNucleotide.value
- return true
+ variants.value.filter((m) => {
+ const bucket = assayLevelBucket(m.assayLevel)
+ return bucket === 'amino acid' ? showProtein.value : showNucleotide.value
})
)
- // ── Selection ─────────────────────────────────────────────
- const selectedVariant = computed(() => variants.value.find((v) => v.content.urn === selectedVariantUrn.value))
- const selectedVariantDetail = computed(() => {
- if (!selectedVariantUrn.value) return null
- return variantDetailCache.value[selectedVariantUrn.value] || null
- })
- const selectedScoreSet = computed(() => selectedVariantDetail.value?.scoreSet || null)
- const selectedVariantName = computed(() => {
- if (!selectedVariant.value) return null
- const v = selectedVariant.value.content
- const mapped = v.mappedVariants.find((m) => m.current)
- // postMapped is typed as `unknown` in the schema — cast to access VRS expression fields
- const postMapped = mapped?.postMapped as {expressions?: {value?: string}[]} | undefined
- return postMapped?.expressions?.[0]?.value || v.hgvsNt || v.hgvsPro || v.hgvsSplice || null
- })
- const selectedClingenAlleleId = computed(() => {
- const mapped = selectedVariantDetail.value?.mappedVariants.find((m) => m.current)
- return mapped?.clingenAlleleId || null
- })
-
- // ── Scores ────────────────────────────────────────────────
- const selectedScoreSetUrn = computed(() => selectedScoreSet.value?.urn || null)
- const scores = computed(() => {
- if (!selectedScoreSetUrn.value) return null
- return scoresCache.value[selectedScoreSetUrn.value] || null
- })
- const variantScoreRow = computed(() => (scores.value || []).find((s) => s.accession === selectedVariantUrn.value))
- const selectedVariantScore = computed(() => variantScoreRow.value?.scores?.score ?? null)
- const selectedVariantGnomad = computed(() => gnomadFromVariantRow(variantScoreRow.value))
-
- // ── Calibration ───────────────────────────────────────────
- const selectedCalibrationObject = computed(() => {
- if (!selectedCalibration.value || !selectedScoreSet.value?.scoreCalibrations) return null
- return (
- selectedScoreSet.value.scoreCalibrations.find((c: ScoreCalibration) => c.urn === selectedCalibration.value) ||
- null
- )
- })
-
- const selectedVariantScoreAsNumber = computed(() => {
- const s = selectedVariantScore.value
- if (s == null || s === 'NA') return null
- const n = typeof s === 'number' ? s : Number(s)
- return Number.isNaN(n) ? null : n
+ // ── Composed sub-domains ──────────────────────────────────
+ const cache = useMeasurementCache(asOf)
+ const selection = useMeasurementSelection(variants, filteredVariants, highlightUrn, cache)
+
+ // An allele is "measured" if any measurement's envelope flags it as that measurement's focus. Read from
+ // the prefetched details, so it is stable as the selection changes (unlike `isFocus` on one envelope).
+ const measuredDigests = computed(() => {
+ const digests = new Set()
+ for (const m of variants.value) {
+ const alleles = cache.variantDetails.value[m.variantUrn]?.alleles ?? {}
+ for (const [digest, identity] of Object.entries(alleles)) {
+ if (identity.isFocus) digests.add(digest)
+ }
+ }
+ return digests
})
- const calibrationResolution = useCalibrationResolution(
- selectedCalibrationObject,
- selectedVariantUrn,
- selectedVariantScoreAsNumber
- )
-
// The selected variant's own status as one of the active calibration's controls, if it was used
// as one — distinct from `calibrationResolution`, which classifies the variant by score.
const selectedVariantControlStatus = computed(() => {
- const urn = selectedVariantUrn.value
+ const urn = selection.selectedVariantUrn.value
if (!urn) return null
- return selectedCalibrationObject.value?.controls?.find((c) => c.variantUrn === urn)?.clinicalStatus ?? null
+ return selection.selectedCalibrationObject.value?.controls?.find((c) => c.variantUrn === urn)?.clinicalStatus ?? null
})
// ── Page-level ────────────────────────────────────────────
const geneName = computed(() => {
- const firstVariant = variants.value[0]?.content
- const targets = firstVariant?.scoreSet?.targetGenes
- return targets?.length > 0 ? targets[0].name || null : null
- })
- const uniqueAssayCount = computed(() => {
- const urns = new Set(variants.value.map((v) => v.content.scoreSet?.urn).filter(Boolean))
- return urns.size
+ const firstUrn = variants.value[0]?.variantUrn
+ if (!firstUrn) return null
+ const targets = cache.scoreSets.value[scoreSetUrnOf(firstUrn)]?.targetGenes
+ return targets && targets.length > 0 ? targets[0].name || null : null
})
+ const uniqueAssayCount = computed(() => new Set(variants.value.map((m) => m.scoreSetUrn)).size)
- // ── Data fetching ─────────────────────────────────────────
- async function fetchVariantDetail(variantUrn: string) {
- if (variantDetailCache.value[variantUrn]) return
- try {
- const detail = await getVariantDetail(variantUrn)
- variantDetailCache.value[variantUrn] = detail
- const scoreSetUrn = detail.scoreSet?.urn
- if (scoreSetUrn) {
- fetchScores(scoreSetUrn)
- }
- } catch (error) {
- console.error(`Error fetching variant detail for "${variantUrn}"`, error)
- }
- }
-
- async function fetchScores(scoreSetUrn: string) {
- if (scoresCache.value[scoreSetUrn]) return
- try {
- const data = await getVariantPageScoreSetData(scoreSetUrn)
- scoresCache.value = {
- ...scoresCache.value,
- [scoreSetUrn]: parseScoreSetVariantData(data)
- }
- } catch (error) {
- console.error(`Error fetching scores for score set "${scoreSetUrn}"`, error)
- }
+ function getKeyword(scoreSetUrn: string | null | undefined, key: string): string | null {
+ if (!scoreSetUrn) return null
+ return getExperimentKeyword(cache.scoreSets.value[scoreSetUrn]?.experiment, key)
}
+ // ── Data fetching ─────────────────────────────────────────
async function fetchVariants() {
variants.value = []
- variantDetailCache.value = {}
- scoresCache.value = {}
+ cache.clear()
+ // New query epoch (anchor / as_of / superseded changed). Fresh reads are keyed by as_of/superseded in
+ // the memoized api layer, so a changed axis always misses the cache and hits the network.
+ const epoch = ++queryEpoch
variantsStatus.value = 'Loading'
+ if (!clingenAlleleId.value) {
+ variantsStatus.value = 'Loaded'
+ return
+ }
+
try {
- const results = await lookupVariantsByClingenId([clingenAlleleId.value])
- const lookup = results[0]
-
- if (clingenAlleleId.value.startsWith('CA')) {
- const nucleotideVariants = (lookup?.exactMatch?.variantEffectMeasurements || []).map((entry) => ({
- content: entry,
- type: 'nucleotide' as const
- }))
- const proteinVariants = (
- lookup?.equivalentAa?.flatMap((entry) => entry.variantEffectMeasurements || []) || []
- ).map((entry) => ({content: entry, type: 'protein' as const}))
- const associatedNucleotideVariants = (
- lookup?.equivalentNt?.flatMap((entry) => entry.variantEffectMeasurements || []) || []
- ).map((entry) => ({content: entry, type: 'associatedNucleotide' as const}))
- variants.value = [...nucleotideVariants, ...proteinVariants, ...associatedNucleotideVariants]
- } else if (clingenAlleleId.value.startsWith('PA')) {
- const proteinVariants = (lookup?.exactMatch?.variantEffectMeasurements || []).map((entry) => ({
- content: entry,
- type: 'protein' as const
- }))
- const nucleotideVariants = (
- lookup?.equivalentNt?.flatMap((entry) => entry.variantEffectMeasurements || []) || []
- ).map((entry) => ({content: entry, type: 'nucleotide' as const}))
- variants.value = [...proteinVariants, ...nucleotideVariants]
+ // The API order IS the default (direct-first, strongest-evidence) — never re-ranked here.
+ const measurements = await getAlleleMeasurements(clingenAlleleId.value, {
+ includeSuperseded: includeSuperseded.value,
+ asOf: asOf.value ?? undefined
+ })
+
+ // A newer query axis changed while this was in flight; let that fetch own the state so this slower
+ // stale response can't overwrite it.
+ if (epoch !== queryEpoch) return
+ variants.value = measurements
+
+ // Citation path, initial load ONLY: if the page opened on a `?variant=` deep link absent from the
+ // current-only list (e.g. a cited *superseded* measurement), enable superseded once so it resolves and its
+ // banner shows. The watcher refetches. Guarded by the one-shot flag so a later manual toggle-off is
+ // honored (switches to a current variant below) instead of flipping superseded back on.
+ //
+ // NOTE: this deliberately triggers a second full fetch/clear cycle on load (current-only, then
+ // superseded). It's correct but redundant — a proper query cache would dedup the shared
+ // reads for free. Not worth hand-collapsing before such a migration.
+ const shouldHonorCitation =
+ !honoredInitialHighlight &&
+ highlightUrn.value &&
+ !includeSuperseded.value &&
+ !variants.value.some((m) => m.variantUrn === highlightUrn.value)
+ honoredInitialHighlight = true
+ if (shouldHonorCitation) {
+ // Stay 'Loading' through the handoff — the watcher's refetch owns the terminal status, so we skip
+ // the transient 'Loaded' that would otherwise flicker Loaded→Loading→Loaded.
+ includeSuperseded.value = true
+ return
}
variantsStatus.value = 'Loaded'
-
if (variants.value.length === 0) return
- const firstUrn = variants.value[0].content.urn
- if (!firstUrn) return
- await fetchVariantDetail(firstUrn)
- selectedVariantUrn.value = firstUrn
-
- // Background-fetch remaining variant details
- const remainingUrns = variants.value
- .slice(1)
- .map((v) => v.content.urn)
- .filter((urn): urn is string => urn != null)
- for (const urn of remainingUrns) {
- fetchVariantDetail(urn)
+ // Default selection = the `?variant=` highlight if it's in the list, else the first measurement.
+ // Writing the selection triggers useMeasurementSelection's watcher to load the selected detail.
+ const highlighted = highlightUrn.value && variants.value.find((m) => m.variantUrn === highlightUrn.value)
+ const selected = highlighted ? highlightUrn.value! : variants.value[0].variantUrn
+ selection.selectedVariantUrn.value = selected
+
+ // Prioritize the display variant: load its detail + score set (both quick, and enough to explore —
+ // score, classification, identity, assay facts) before fanning out. The selection watcher loads the
+ // heavier lean score distribution (histogram only), which rides behind this and the prefetch below.
+ await cache.loadDetail(selected)
+ if (epoch !== queryEpoch) return
+
+ // Background-fetch the rest (details + their score sets, which back the cards' assay facts), capped so
+ // a large equivalence class doesn't fire a request storm. Skip if a newer query epoch has started.
+ for (const m of variants.value) {
+ if (m.variantUrn !== selected) {
+ prefetchLimit(() => (epoch === queryEpoch ? cache.loadDetail(m.variantUrn) : Promise.resolve()))
+ }
}
} catch (error) {
console.error('Error while loading variants', error)
@@ -301,7 +241,7 @@ export function useVariantLookup(
}
async function fetchVariantAnnotations(annotationType: string) {
- const activeVariant = selectedVariantDetail.value
+ const activeVariant = selection.selectedVariantDetail.value
if (!activeVariant?.urn || downloadInProgressLabel.value !== null) return
downloadInProgressLabel.value = 'annotations'
@@ -325,7 +265,7 @@ export function useVariantLookup(
* `fetchVariantAnnotations`. Omitting `namespaces` asks the server for its default set.
*/
async function downloadVariantCsvFile(namespaces?: string[]) {
- const activeVariant = selectedVariantDetail.value
+ const activeVariant = selection.selectedVariantDetail.value
if (!activeVariant?.urn || downloadInProgressLabel.value !== null) return
downloadInProgressLabel.value = 'variant CSV'
@@ -344,116 +284,30 @@ export function useVariantLookup(
}
}
- function selectVariant(urn: string | null | undefined) {
- selectedVariantUrn.value = urn ?? null
- }
-
- // ── Per-variant helpers (for measurement cards) ───────────
- function getKeyword(variantContent: VariantEffectMeasurementWithShortScoreSet, key: string): string | null {
- return getExperimentKeyword(variantContent?.scoreSet?.experiment, key)
- }
-
- function getAbnormalOddsPath(urn: string | null | undefined): string | null {
- if (!urn) return null
- return getClassificationOddsPath(getPrimaryCalibration(variantDetailCache.value[urn]?.scoreSet), 'abnormal')
- }
-
- function getNormalOddsPath(urn: string | null | undefined): string | null {
- if (!urn) return null
- return getClassificationOddsPath(getPrimaryCalibration(variantDetailCache.value[urn]?.scoreSet), 'normal')
- }
-
- function getVariantScoreRange(variantUrn: string | null | undefined): FunctionalClassification | null {
- if (!variantUrn) return null
- const detail = variantDetailCache.value[variantUrn]
- const scoreSetUrn = detail?.scoreSet?.urn
- if (!scoreSetUrn) return null
-
- const cachedScores = scoresCache.value[scoreSetUrn]
- if (!cachedScores) return null
-
- const scoreRow = cachedScores.find((s) => s.accession === variantUrn)
- const rawScore = scoreRow?.scores?.score
- if (rawScore == null || rawScore === 'NA') return null
- const score = typeof rawScore === 'number' ? rawScore : Number(rawScore)
- if (Number.isNaN(score)) return null
-
- const cal = getPrimaryCalibration(detail?.scoreSet)
- if (!cal?.functionalClassifications) return null
-
- return cal.functionalClassifications.find((r) => functionalClassificationContainsVariant(r, score)) || null
- }
-
- function getVariantClassification(variantUrn: string | null | undefined): string | null {
- return getVariantScoreRange(variantUrn)?.functionalClassification || null
- }
-
- function getVariantEvidenceCode(variantUrn: string | null | undefined): string | null {
- return formatEvidenceCode(getVariantScoreRange(variantUrn)) || null
- }
-
- // ── Watchers ──────────────────────────────────────────────
- watch(
- clingenAlleleId,
- async (newValue, oldValue) => {
- if (newValue !== oldValue) {
- await fetchVariants()
- }
- },
- {immediate: true}
- )
-
- // When filters hide the currently selected variant, fall back to the first visible one.
- watch(filteredVariants, (visible) => {
- if (selectedVariantUrn.value && !visible.some((v) => v.content.urn === selectedVariantUrn.value)) {
- selectedVariantUrn.value = visible[0]?.content.urn ?? null
- }
- })
-
- watch(selectedVariantUrn, async (newUrn) => {
- selectedCalibration.value = null
- if (newUrn) {
- await fetchVariantDetail(newUrn)
- }
- })
+ // Any query-axis change (anchor, superseded scope, content valid-time) refetches; fetchVariants clears
+ // the per-URN caches so detail/scores re-resolve under the new as_of.
+ watch([clingenAlleleId, includeSuperseded, asOf], fetchVariants, {immediate: true})
return {
+ ...selection,
clingenAllele,
variants,
variantsStatus,
fetchVariants,
showNucleotide,
showProtein,
- showAssociatedNucleotide,
+ includeSuperseded,
+ asOf,
nucleotideCount,
proteinCount,
- associatedNucleotideCount,
filteredVariants,
- selectedVariantUrn,
- selectVariant,
- selectedVariant,
- selectedVariantDetail,
- selectedVariantName,
- selectedClingenAlleleId,
- selectedScoreSet,
- selectedScoreSetUrn,
- scores,
- variantScoreRow,
- selectedVariantScore,
- selectedVariantGnomad,
- selectedCalibration,
- selectedCalibrationObject,
selectedVariantControlStatus,
- calibrationResolution,
- getAbnormalOddsPath,
- getNormalOddsPath,
- getVariantClassification,
- getVariantEvidenceCode,
+ measuredDigests,
getKeyword,
geneName,
uniqueAssayCount,
+ downloadInProgressLabel,
fetchVariantAnnotations,
- downloadVariantCsvFile,
- downloadInProgressLabel
+ downloadVariantCsvFile
}
}
diff --git a/src/directives/key-term.ts b/src/directives/key-term.ts
new file mode 100644
index 00000000..44138c97
--- /dev/null
+++ b/src/directives/key-term.ts
@@ -0,0 +1,62 @@
+import type {Directive, DirectiveBinding} from 'vue'
+
+import {useKeyDrawer} from '@/composables/use-key-drawer'
+
+const TITLE = 'What does this mean? — opens the Key'
+
+interface KeyTermEl extends HTMLElement {
+ __keyTerm?: {onClick: (e: Event) => void; onKeydown: (e: KeyboardEvent) => void}
+}
+
+/**
+ * `v-key-term="'assay-level'"` — turns any badge/tag into a deep link into the vocabulary Key drawer.
+ * Adds the click + keyboard handlers, ARIA/affordance attributes, and the `.key-term` hover style, so
+ * the deep-link behaviour lives in one place rather than being copy-pasted onto every badge. Applies to
+ * the root element (works on plain elements and single-root components like MvEvidenceTag).
+ *
+ * `stopPropagation` keeps a badge click from also triggering an enclosing clickable card.
+ */
+function bind(el: KeyTermEl, binding: DirectiveBinding) {
+ const term = binding.value
+ if (!term) return
+
+ const {open} = useKeyDrawer()
+
+ const onClick = (e: Event) => {
+ e.stopPropagation()
+ open(term)
+ }
+ const onKeydown = (e: KeyboardEvent) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault()
+ e.stopPropagation()
+ open(term)
+ }
+ }
+
+ el.addEventListener('click', onClick)
+ el.addEventListener('keydown', onKeydown)
+ el.classList.add('key-term')
+ el.setAttribute('role', 'button')
+ el.setAttribute('tabindex', '0')
+ if (!el.getAttribute('title')) el.setAttribute('title', TITLE)
+ el.__keyTerm = {onClick, onKeydown}
+}
+
+function unbind(el: KeyTermEl) {
+ if (!el.__keyTerm) return
+ el.removeEventListener('click', el.__keyTerm.onClick)
+ el.removeEventListener('keydown', el.__keyTerm.onKeydown)
+ delete el.__keyTerm
+}
+
+export const vKeyTerm: Directive = {
+ mounted: bind,
+ updated(el, binding) {
+ if (binding.value !== binding.oldValue) {
+ unbind(el)
+ bind(el, binding)
+ }
+ },
+ unmounted: unbind
+}
diff --git a/src/glossary.ts b/src/glossary.ts
new file mode 100644
index 00000000..0148906d
--- /dev/null
+++ b/src/glossary.ts
@@ -0,0 +1,37 @@
+import config from '@/config'
+import type {KeySection} from '@/composables/use-key-drawer'
+import {COORDINATE_FRAME_KEY_SECTION} from '@/composables/use-variant-coordinates'
+import {ACMG_KEY_SECTION} from '@/lib/acmg'
+import {CONFIDENCE_KEY_SECTION} from '@/lib/allele-grouping'
+import {CLINICAL_SIGNIFICANCE_KEY_SECTION, INFERRED_CONTROL_KEY_SECTION} from '@/lib/clinvar-controls'
+import {FUNCTIONAL_IMPACT_KEY_SECTION} from '@/lib/functional-impact'
+import {
+ AS_OF_KEY_SECTION,
+ CALIBRATION_KEY_SECTION,
+ CONSEQUENCE_KEY_SECTION,
+ NMD_KEY_SECTION,
+ THIS_VARIANT_KEY_SECTION,
+ SUPERSEDED_KEY_SECTION
+} from '@/lib/glossary-prose'
+import {POPULATION_KEY_SECTION} from '@/lib/gnomad'
+import {ASSAY_LEVEL_KEY_SECTION, RELATIONSHIP_KEY_SECTION} from '@/lib/measurement-types'
+
+export const GLOSSARY_DOCS_URL = `${config.appBaseUrl}/docs/mavedb/interpreting-annotated-variants.html`
+
+export const GLOSSARY_SECTIONS: KeySection[] = [
+ THIS_VARIANT_KEY_SECTION,
+ RELATIONSHIP_KEY_SECTION,
+ ASSAY_LEVEL_KEY_SECTION,
+ CONFIDENCE_KEY_SECTION,
+ COORDINATE_FRAME_KEY_SECTION,
+ CONSEQUENCE_KEY_SECTION,
+ FUNCTIONAL_IMPACT_KEY_SECTION,
+ CALIBRATION_KEY_SECTION,
+ ACMG_KEY_SECTION,
+ POPULATION_KEY_SECTION,
+ CLINICAL_SIGNIFICANCE_KEY_SECTION,
+ INFERRED_CONTROL_KEY_SECTION,
+ NMD_KEY_SECTION,
+ AS_OF_KEY_SECTION,
+ SUPERSEDED_KEY_SECTION
+]
diff --git a/src/lib/acmg.ts b/src/lib/acmg.ts
new file mode 100644
index 00000000..a5129ce7
--- /dev/null
+++ b/src/lib/acmg.ts
@@ -0,0 +1,68 @@
+/**
+ * The clinical bridge of the calibration layer: the ACMG functional-evidence vocabulary that translates
+ * an assay's functional impact into clinical evidence. `PS3`/`BS3` are the ACMG functional-evidence
+ * criteria; the strength ladder and point values are the ACMG/ClinGen SVI (Bayesian points) system.
+ * Kept dependency-light (schema types only) so read-side surfaces can import it without pulling in the
+ * calibration container's axios/histogram dependencies.
+ */
+
+import {components} from '@/schema/openapi'
+import type {KeySection} from '@/composables/use-key-drawer'
+
+type ScoreCalibrationFunctionalClassification =
+ components['schemas']['mavedb__view_models__score_calibration__FunctionalClassification']
+
+/** ACMG functional-evidence criteria: benign-supporting (BS3) and pathogenic-supporting (PS3). */
+export const BENIGN_CRITERION = 'BS3'
+export const PATHOGENIC_CRITERION = 'PS3'
+
+/** Points each evidence-strength tier contributes on the ClinGen SVI (Bayesian) scale. */
+export const EVIDENCE_STRENGTH_AS_POINTS = {
+ VERY_STRONG: 8,
+ STRONG: 4,
+ MODERATE_PLUS: 3,
+ MODERATE: 2,
+ SUPPORTING: 1
+}
+
+/** The evidence-strength tiers, strongest first. */
+export const EVIDENCE_STRENGTH = Object.keys(EVIDENCE_STRENGTH_AS_POINTS)
+
+/**
+ * The two ACMG functional-evidence criteria with their Key-drawer glosses, keyed by criterion code so the
+ * drawer labels are the very constants the evidence tags render — they can't drift. Pathogenic first
+ * (display order).
+ */
+export const ACMG_CRITERIA: Record = {
+ [PATHOGENIC_CRITERION]: {
+ label: PATHOGENIC_CRITERION,
+ definition: 'Functional evidence supporting a pathogenic classification.'
+ },
+ [BENIGN_CRITERION]: {
+ label: BENIGN_CRITERION,
+ definition: 'Functional evidence supporting a benign classification.'
+ }
+}
+
+export const ACMG_KEY_SECTION: KeySection = {
+ id: 'acmg',
+ title: 'ACMG functional evidence',
+ gloss: 'How the functional result maps onto clinical-classification evidence.',
+ terms: [
+ ...Object.values(ACMG_CRITERIA),
+ {
+ label: 'Evidence strength',
+ definition: 'How much weight the evidence carries: supporting → moderate → moderate+ → strong → very strong.'
+ },
+ {label: 'OddsPath', definition: 'The odds of pathogenicity implied by the score; sets the evidence strength.'}
+ ]
+}
+
+export function formatEvidenceCode(
+ classification: ScoreCalibrationFunctionalClassification | null | undefined
+): string {
+ if (!classification?.acmgClassification?.evidenceStrength) return ''
+ const criterion = classification.acmgClassification.criterion
+ const strength = classification.acmgClassification.evidenceStrength.toUpperCase()
+ return `${criterion}_${strength}`
+}
diff --git a/src/lib/allele-grouping.test.ts b/src/lib/allele-grouping.test.ts
new file mode 100644
index 00000000..d3de98d6
--- /dev/null
+++ b/src/lib/allele-grouping.test.ts
@@ -0,0 +1,210 @@
+import {describe, expect, it} from 'vitest'
+
+import {
+ ALLELE_CONFIDENCE,
+ confidenceBadge,
+ groupAlleles,
+ titleMember,
+ type GroupAllelesInput
+} from '@/lib/allele-grouping'
+import type {components} from '@/schema/openapi'
+
+type AlleleIdentity = components['schemas']['AlleleIdentity']
+type AlleleAnnotations = components['schemas']['AlleleAnnotations']
+
+function vep(consequence: string): AlleleAnnotations {
+ return {vep: {consequence}} as AlleleAnnotations
+}
+
+function run(overrides: Partial) {
+ return groupAlleles({
+ alleles: {},
+ annotations: {},
+ pageClingenAlleleId: null,
+ ...overrides
+ })
+}
+
+const none: ReadonlySet = new Set()
+
+describe('groupAlleles — projection pairing + confidence', () => {
+ // Nucleotide (cdna) assay: the measured cdna folds into its pair's group with its genomic projection;
+ // the protein apex is a deterministic projection, unpaired.
+ const nucleotide: Record = {
+ c: {level: 'cdna', hgvs: 'NM_x:c.6C>T', relation: null, isFocus: true, projectionOf: 'g'},
+ g: {level: 'genomic', hgvs: 'NC_x:g.100C>T', relation: 'coordinate_representation_of', isFocus: false, derivation: 'projection', projectionOf: 'c'},
+ p: {level: 'protein', hgvs: 'NP_x:p.Leu2Phe', relation: 'translation_of', isFocus: false, derivation: 'projection'}
+ }
+
+ it('collapses the measured c↔g pair into one group and keeps the apex separate', () => {
+ const groups = run({alleles: nucleotide})
+ expect(groups).toHaveLength(2)
+
+ const [pair, apex] = groups
+ // Measured pair floats to the top and carries both levels; its confidence comes from the genomic
+ // projection member (the focus/measured member has no derivation of its own).
+ expect(pair.measured).toBe(true)
+ expect(pair.derivation).toBe('projection')
+ expect(pair.members.map((m) => m.level)).toEqual(['genomic', 'cdna'])
+
+ expect(apex.measured).toBe(false)
+ expect(apex.members).toHaveLength(1)
+ expect(apex.derivation).toBe('projection')
+ })
+
+ // Protein assay: the protein apex is the focus/measured allele; the nucleotide fan-out is candidate pairs.
+ const protein: Record = {
+ p: {level: 'protein', hgvs: 'NP_x:p.Leu6Gly', relation: null, isFocus: true},
+ c1: {level: 'cdna', hgvs: 'NM_x:c.16C>G', relation: 'encodes', isFocus: false, derivation: 'candidate', projectionOf: 'g1'},
+ g1: {level: 'genomic', hgvs: 'NC_x:g.200C>G', relation: 'coordinate_representation_of', isFocus: false, derivation: 'candidate', projectionOf: 'c1'},
+ c2: {level: 'cdna', hgvs: 'NM_x:c.16C>A', relation: 'encodes', isFocus: false, derivation: 'candidate', projectionOf: 'g2'},
+ g2: {level: 'genomic', hgvs: 'NC_x:g.200C>A', relation: 'coordinate_representation_of', isFocus: false, derivation: 'candidate', projectionOf: 'c2'}
+ }
+
+ it('keeps the apex measured and collapses each candidate hypothesis into one group', () => {
+ const groups = run({alleles: protein})
+ expect(groups).toHaveLength(3) // apex + 2 candidate pairs
+
+ expect(groups[0].measured).toBe(true) // apex pinned first
+ const candidates = groups.filter((g) => g.derivation === 'candidate')
+ expect(candidates).toHaveLength(2)
+ expect(candidates.every((g) => g.members.length === 2 && !g.measured)).toBe(true)
+ })
+
+ it('flags a projection pair whose annotations diverge instead of hiding the difference', () => {
+ const groups = run({
+ alleles: nucleotide,
+ annotations: {c: vep('missense_variant'), g: vep('intron_variant'), p: vep('missense_variant')}
+ })
+ const pair = groups.find((g) => g.members.length === 2)!
+ expect(pair.annotationsMatch).toBe(false)
+ })
+
+ it('treats a matching pair as one deduplicated block', () => {
+ const groups = run({
+ alleles: nucleotide,
+ annotations: {c: vep('missense_variant'), g: vep('missense_variant')}
+ })
+ const pair = groups.find((g) => g.members.length === 2)!
+ expect(pair.annotationsMatch).toBe(true)
+ })
+
+ it('coalesces missing-vs-present annotations (missingness is not a difference)', () => {
+ // Only the cdna member is annotated; the genomic member has nothing. This must NOT read as divergence.
+ const groups = run({
+ alleles: nucleotide,
+ annotations: {c: vep('missense_variant')}
+ })
+ const pair = groups.find((g) => g.members.length === 2)!
+ expect(pair.annotationsMatch).toBe(true)
+ expect(pair.coalescedAnnotations?.vep?.consequence).toBe('missense_variant')
+ })
+
+ it('coalesces disjoint fields across levels into one block', () => {
+ const groups = run({
+ alleles: nucleotide,
+ annotations: {
+ c: {vep: {consequence: 'missense_variant'}},
+ g: {gnomad: {alleleFrequency: 0.01}}
+ } as unknown as Record
+ })
+ const pair = groups.find((g) => g.members.length === 2)!
+ expect(pair.annotationsMatch).toBe(true)
+ expect(pair.coalescedAnnotations?.vep?.consequence).toBe('missense_variant')
+ expect(pair.coalescedAnnotations?.gnomad?.alleleFrequency).toBe(0.01)
+ })
+
+ it('leaves a projection-failed candidate (dangling projectionOf) as a one-member group', () => {
+ const groups = run({
+ alleles: {
+ p: {level: 'protein', hgvs: 'NP_x:p.Leu6Gly', relation: null, isFocus: true},
+ c1: {level: 'cdna', hgvs: 'NM_x:c.16C>G', relation: 'encodes', isFocus: false, derivation: 'candidate', projectionOf: 'missing'}
+ }
+ })
+ const candidate = groups.find((g) => g.derivation === 'candidate')!
+ expect(candidate.members).toHaveLength(1)
+ })
+
+ // `measured` keys off `isFocus`, not relation == null: the focus allele AND any non-member link both
+ // carry relation null, so a null-relation non-focus allele must NOT be pinned.
+ it('does not flag a null-relation non-focus allele as measured', () => {
+ const groups = run({
+ alleles: {
+ n: {level: 'protein', hgvs: 'NP_x:p.Leu6Gly', relation: null, isFocus: false, derivation: 'projection'}
+ }
+ })
+ expect(groups).toHaveLength(1)
+ expect(groups[0].measured).toBe(false)
+ })
+
+ it('badges selected over derivation, maps projection→Resolved / convergent→Convergent / candidate→Candidate, else null', () => {
+ // The selected measurement's variant wins even if a derivation is also present.
+ expect(confidenceBadge({measured: true, derivation: 'projection', members: []}, none)).toBe(
+ ALLELE_CONFIDENCE.selected
+ )
+ expect(confidenceBadge({measured: false, derivation: 'projection', members: []}, none)).toBe(
+ ALLELE_CONFIDENCE.projection
+ )
+ // A synonymous cousin under a nucleotide assay: a distinct change sharing the consequence, not ambiguous.
+ expect(confidenceBadge({measured: false, derivation: 'convergent', members: []}, none)).toBe(
+ ALLELE_CONFIDENCE.convergent
+ )
+ // The protein-assay reverse-translation fan-out: genuinely ambiguous.
+ expect(confidenceBadge({measured: false, derivation: 'candidate', members: []}, none)).toBe(
+ ALLELE_CONFIDENCE.candidate
+ )
+ // The focus/measured allele carries no derivation; on its own (measured false) it has no badge.
+ expect(confidenceBadge({measured: false, derivation: null, members: []}, none)).toBeNull()
+ // The user-facing labels are decoupled from the enum keys.
+ expect(ALLELE_CONFIDENCE.projection.label).toBe('Resolved')
+ expect(ALLELE_CONFIDENCE.convergent.label).toBe('Convergent')
+ })
+
+ it('surfaces distinct linked CAIDs, excluding the page anchor', () => {
+ const groups = run({
+ alleles: {
+ c: {level: 'cdna', hgvs: 'NM_x:c.6C>T', relation: null, clingenAlleleId: 'CA1', isFocus: true, projectionOf: 'g'},
+ g: {level: 'genomic', hgvs: 'NC_x:g.100C>T', relation: 'coordinate_representation_of', clingenAlleleId: 'CA1', isFocus: false, derivation: 'projection', projectionOf: 'c'}
+ },
+ pageClingenAlleleId: 'CA1'
+ })
+ // The shared CAID is the page anchor, so no outward link and the group is flagged page-root.
+ expect(groups[0].pageRoot).toBe(true)
+ expect(groups[0].clingenLinks).toEqual([])
+ })
+})
+
+describe('titleMember', () => {
+ it('leads a projection pair with its cDNA member', () => {
+ const [pair] = run({
+ alleles: {
+ c: {level: 'cdna', hgvs: 'NM_x:c.6C>T', relation: null, isFocus: true, projectionOf: 'g'},
+ g: {level: 'genomic', hgvs: 'NC_x:g.100C>T', relation: null, isFocus: false, projectionOf: 'c'}
+ }
+ })
+ expect(titleMember(pair)?.hgvs).toBe('NM_x:c.6C>T')
+ })
+
+ it('falls back to protein, then the first member', () => {
+ const [protein] = run({alleles: {p: {level: 'protein', hgvs: 'NP_x:p.Leu2Phe', relation: null, isFocus: true}}})
+ expect(titleMember(protein)?.hgvs).toBe('NP_x:p.Leu2Phe')
+ expect(titleMember({members: []})).toBeNull()
+ })
+})
+
+describe('confidenceBadge — page-wide measured', () => {
+ const [pair] = run({
+ alleles: {
+ c: {level: 'cdna', hgvs: 'NM_x:c.6C>T', relation: null, isFocus: false, derivation: 'convergent', projectionOf: 'g'},
+ g: {level: 'genomic', hgvs: 'NC_x:g.100C>T', relation: null, isFocus: false, derivation: 'convergent', projectionOf: 'c'}
+ }
+ })
+
+ it('reads Measured for a variant measured elsewhere, overriding its relative derivation', () => {
+ expect(confidenceBadge(pair, new Set(['g']))).toBe(ALLELE_CONFIDENCE.measured)
+ })
+
+ it('falls back to the relative derivation when no member is measured anywhere', () => {
+ expect(confidenceBadge(pair, new Set(['other']))).toBe(ALLELE_CONFIDENCE.convergent)
+ })
+})
diff --git a/src/lib/allele-grouping.ts b/src/lib/allele-grouping.ts
new file mode 100644
index 00000000..f204a883
--- /dev/null
+++ b/src/lib/allele-grouping.ts
@@ -0,0 +1,267 @@
+/**
+ * @fileoverview
+ * Allele grouping: collapse a variant's alleles into one or more groups for display, pairing c↔g projections
+ * and deduplicating their annotations. The groups are the rendered entries in the variant detail's
+ * "alleles" section, and the source of truth for the histogram's per-allele controls.
+ */
+
+import _ from 'lodash'
+
+import type {components} from '@/schema/openapi'
+import type {KeySection} from '@/composables/use-key-drawer'
+
+type AlleleIdentity = components['schemas']['AlleleIdentity']
+type AlleleAnnotations = components['schemas']['AlleleAnnotations']
+
+// ── CONFIDENCE BADGES: how a group's coordinate was established ──
+
+/**
+ * Confidence/provenance axis (orthogonal to Cat-VRS's `relation`); strongest confidence first. The
+ * measured allele carries no derivation — it is flagged `isFocus` on the identity instead (the API
+ * dropped the `authoritative` derivation value in favour of that focus marker).
+ */
+export type Derivation = 'projection' | 'convergent' | 'candidate'
+const DERIVATION_RANK: Record = {projection: 0, convergent: 1, candidate: 2}
+
+// Level display order (genomic → coding → protein) so a group reads bottom-up through the layer stack.
+const LEVEL_ORDER: Record = {genomic: 0, cdna: 1, protein: 2}
+
+/** A member of an allele group: a single level's identity, its annotations, and whether it is the page root. */
+export interface AlleleMember extends AlleleIdentity {
+ digest: string
+ annotations: AlleleAnnotations | null
+ // Whether this allele is the CAID/PAID the page is anchored on.
+ pageRoot: boolean
+}
+
+/**
+ * A group of alleles rendered as one entry. Either a single allele (the protein apex, an unpaired
+ * measured allele, or a projection-failed one-member candidate) or a c↔g projection pair collapsed into
+ * one (the same change at two levels).
+ */
+export interface AlleleGroup {
+ key: string
+ members: AlleleMember[]
+ // Whether the group contains the view's focus allele (the measured allele on the variant page).
+ measured: boolean
+ // Whether this group contains the page-anchor allele.
+ pageRoot: boolean
+ // Grouped confidence: the strongest derivation among the members.
+ derivation: Derivation | null
+ // Whether the members' annotations can render as one block.
+ annotationsMatch: boolean
+ // The per-field union of the members' annotations (present-wins).
+ coalescedAnnotations: AlleleAnnotations | null
+ // Distinct linked CAIDs to surface.
+ clingenLinks: string[]
+}
+
+/** A provenance badge + its Key-drawer gloss: how a group's coordinate was established. */
+export interface ConfidenceBadge {
+ label: string
+ class: string
+ definition: string
+}
+
+// Single source for the confidence axis (badges + the Key drawer's "confidence" section), keyed by outcome
+// rather than raw `Derivation`. Each value paints from its own same-named CSS token (see the "Allele
+// relationship axis" block in assets/app.css) so a token can never drift from the label it styles.
+// `convergent` and `candidate` share a hex — both say "not the change that was measured" — but hold
+// separate tokens so they can diverge without touching this file. Insertion order is the drawer's
+// display order.
+export const ALLELE_CONFIDENCE: Record = {
+ // Solid where `measured` is tinted: the selection is a state of the page, `measured` a fact about a variant.
+ selected: {
+ label: 'Selected measurement',
+ class: 'bg-measured text-white',
+ definition: 'The variant assayed by the measurement you have selected.'
+ },
+ measured: {
+ label: 'Measured',
+ class: 'bg-measured-light text-measured',
+ definition: 'Has a measurement of its own on this page, whichever measurement is selected.'
+ },
+ projection: {
+ label: 'Resolved',
+ class: 'bg-resolved-light text-resolved',
+ definition:
+ "Derived from the selected measurement's variant: the same change expressed at an un-measured coordinate level."
+ },
+ convergent: {
+ label: 'Convergent',
+ class: 'bg-convergent-light text-convergent',
+ definition:
+ "A different nucleotide change than the selected measurement's variant, which happens to produce the same protein change."
+ },
+ candidate: {
+ label: 'Candidate',
+ class: 'bg-candidate-light text-candidate',
+ definition:
+ 'A possible nucleotide change encoding a protein-level measurement. This variant is one of several synonymous codons. The assay did not report which one was measured.'
+ }
+}
+
+export const CONFIDENCE_KEY_SECTION: KeySection = {
+ id: 'confidence',
+ title: 'Measured and derived variants',
+ gloss: 'Whether a variant has a measurement of its own, or how it relates to the selected one.',
+ terms: Object.values(ALLELE_CONFIDENCE).map((c) => ({label: c.label, definition: c.definition, class: c.class}))
+}
+
+/**
+ * Badge for a group, strongest claim first: the selected measurement's own variant, then any variant with a
+ * measurement of its own (`measuredDigests`, page-wide), else its derived state relative to the selected
+ * measurement; null when none applies. Measured-ness is a fact about the variant, so it never flips as the
+ * selection moves — only the derived labels are relative.
+ */
+export function confidenceBadge(
+ group: Pick,
+ measuredDigests: ReadonlySet
+): ConfidenceBadge | null {
+ if (group.measured) return ALLELE_CONFIDENCE.selected
+ if (group.members.some((m) => measuredDigests.has(m.digest))) return ALLELE_CONFIDENCE.measured
+ return group.derivation ? (ALLELE_CONFIDENCE[group.derivation] ?? null) : null
+}
+
+/**
+ * The member whose HGVS titles a group. A projection pair holds one change in two coordinate frames
+ * (genomic + coding); lead with cDNA (community-preferred), then protein, then genomic.
+ */
+export function titleMember(group: Pick): AlleleMember | null {
+ return (
+ group.members.find((m) => m.level === 'cdna') ??
+ group.members.find((m) => m.level === 'protein') ??
+ group.members[0] ??
+ null
+ )
+}
+
+// ── GROUPING: collapse a variant's alleles into rendered groups ──
+
+export interface GroupAllelesInput {
+ alleles: Record
+ annotations: Record
+ /** The ClinGen id the page is anchored on. */
+ pageClingenAlleleId: string | null
+}
+
+/**
+ * Merge the members' annotations field-by-field, present-wins. Missingness is not divergence: a field on
+ * only one member is simply carried through. `conflict` is true only when two members both carry a field
+ * and disagree — the case worth flagging as "differs by level".
+ */
+function coalesceAnnotations(members: AlleleMember[]): {merged: AlleleAnnotations | null; conflict: boolean} {
+ const present = members.map((m) => m.annotations).filter((a): a is AlleleAnnotations => a != null)
+ if (present.length === 0) return {merged: null, conflict: false}
+ if (present.length === 1) return {merged: present[0], conflict: false}
+
+ const merged: Record = {}
+ let conflict = false
+ for (const key of _.union(...present.map((a) => Object.keys(a)))) {
+ const values = present.map((a) => (a as Record)[key]).filter((v) => v != null)
+ if (values.length === 0) continue
+ if (values.length > 1 && !values.every((v) => _.isEqual(v, values[0]))) conflict = true
+ merged[key] = values[0]
+ }
+ return {merged: merged as AlleleAnnotations, conflict}
+}
+
+function pickDerivation(members: AlleleMember[]): Derivation | null {
+ let best: Derivation | null = null
+ let bestRank = Infinity
+ for (const m of members) {
+ const rank = m.derivation != null ? DERIVATION_RANK[m.derivation] : undefined
+ if (rank != null && rank < bestRank) {
+ bestRank = rank
+ best = m.derivation as Derivation
+ }
+ }
+ return best
+}
+
+// A digest -> its projection map: the two members of a projection pair (the same change expressed at
+// coding and genomic level). Mutual by construction, but honor a one-sided link too so a pair is never
+// split by iteration order.
+function buildProjectionMap(alleles: Record): Map {
+ const projectionOf = new Map()
+ for (const [digest, identity] of Object.entries(alleles)) {
+ const projection = identity.projectionOf
+ if (projection && projection !== digest && alleles[projection]) {
+ projectionOf.set(digest, projection)
+ projectionOf.set(projection, digest)
+ }
+ }
+ return projectionOf
+}
+
+function makeMember(
+ digest: string,
+ identity: AlleleIdentity,
+ annotations: Record,
+ pageClingenAlleleId: string | null
+): AlleleMember {
+ return {
+ digest,
+ level: identity.level,
+ hgvs: identity.hgvs,
+ clingenAlleleId: identity.clingenAlleleId ?? null,
+ isFocus: identity.isFocus,
+ relation: identity.relation ?? null,
+ derivation: identity.derivation ?? null,
+ annotations: annotations[digest] ?? null,
+ pageRoot: identity.clingenAlleleId != null && identity.clingenAlleleId === pageClingenAlleleId
+ }
+}
+
+/**
+ * Collapse the detail envelope's `alleles` sidecar into rendered groups, pairing each c↔g projection
+ * (linked by `projectionOf`) into one entry and deduplicating its annotations. `derivation` labels the
+ * group's confidence — orthogonal to Cat-VRS `relation`, which stays per member. Groups are ordered
+ * measured/page-root first, then bottom-up by level.
+ */
+export function groupAlleles(input: GroupAllelesInput): AlleleGroup[] {
+ const {alleles, annotations, pageClingenAlleleId} = input
+ const projectionOf = buildProjectionMap(alleles)
+
+ // Pair-and-consume: each allele is visited once; its projection (if any) is pulled in immediately.
+ const done = new Set()
+ const groups: AlleleGroup[] = []
+ for (const [digest, identity] of Object.entries(alleles)) {
+ if (done.has(digest)) continue
+ done.add(digest)
+ const members = [makeMember(digest, identity, annotations, pageClingenAlleleId)]
+ const projection = projectionOf.get(digest)
+ if (projection && !done.has(projection)) {
+ done.add(projection)
+ members.push(makeMember(projection, alleles[projection], annotations, pageClingenAlleleId))
+ }
+ // Sort members genomic → cDNA → protein so the group reads consistently regardless of input order.
+ members.sort((a, b) => (LEVEL_ORDER[a.level ?? ''] ?? 99) - (LEVEL_ORDER[b.level ?? ''] ?? 99))
+
+ // See coalesceAnnotations for the present-wins/conflict rule.
+ const {merged, conflict} = coalesceAnnotations(members)
+
+ groups.push({
+ key: members[0].digest,
+ members,
+ measured: members.some((m) => m.isFocus),
+ pageRoot: members.some((m) => m.pageRoot),
+ derivation: pickDerivation(members),
+ annotationsMatch: !conflict,
+ coalescedAnnotations: merged,
+ clingenLinks: _.uniq(
+ members.map((m) => m.clingenAlleleId).filter((id): id is string => id != null && id !== pageClingenAlleleId)
+ )
+ })
+ }
+
+ // Measured/page-root groups float to the top; within those, measured beats page-root-only.
+ // Remaining groups sort by their first member's level (genomic → cDNA → protein).
+ return groups.sort((a, b) => {
+ const aPinned = a.measured || a.pageRoot
+ const bPinned = b.measured || b.pageRoot
+ if (aPinned !== bPinned) return aPinned ? -1 : 1
+ if (a.measured !== b.measured) return a.measured ? -1 : 1
+ return (LEVEL_ORDER[a.members[0].level ?? ''] ?? 99) - (LEVEL_ORDER[b.members[0].level ?? ''] ?? 99)
+ })
+}
diff --git a/src/lib/annotation-subject.ts b/src/lib/annotation-subject.ts
new file mode 100644
index 00000000..ff722137
--- /dev/null
+++ b/src/lib/annotation-subject.ts
@@ -0,0 +1,14 @@
+/**
+ * The subject allele of an annotation surface (ClinVar, gnomAD), as its digest(s).
+ *
+ * Usually a single digest — the measured allele. But a variant page's subject is one physical allele stored
+ * as two digests: its coding and genomic representations (a c↔g projection pair). External databases attach
+ * to only one of them (no fixed level→source mapping), so both must count as "the subject's own" or a record
+ * on the projection reads as a different allele.
+ */
+export type SubjectDigest = string | string[] | null | undefined
+
+/** Normalize a {@link SubjectDigest} to a set for membership tests. */
+export function toSubjectDigestSet(subject: SubjectDigest): Set {
+ return new Set(subject == null ? [] : Array.isArray(subject) ? subject : [subject])
+}
diff --git a/src/lib/calibrations.ts b/src/lib/calibrations.ts
index 677a334d..87668842 100644
--- a/src/lib/calibrations.ts
+++ b/src/lib/calibrations.ts
@@ -1,52 +1,21 @@
+/**
+ * @fileoverview
+ * Calibration utilities for score interpretation and histogram visualization.
+ */
+
import axios from 'axios'
import {createScoreCalibration, updateScoreCalibration} from '@/api/mavedb'
import type {CalibrationControlStatus} from '@/lib/calibration-controls'
+import {FUNCTIONAL_CLASSIFICATIONS, type FunctionalClassification} from '@/lib/functional-impact'
import {HistogramBin, HistogramShader} from '@/lib/histogram'
import {components} from '@/schema/openapi'
+export type ScoreCalibration = components['schemas']['ScoreCalibration']
+export type ScoreCalibrationFunctionalClassification =
+ components['schemas']['mavedb__view_models__score_calibration__FunctionalClassification']
export type FunctionalClassificationVariants = components['schemas']['FunctionalClassificationVariants']
export type FunctionalClassificationVariant = components['schemas']['VariantEffectMeasurement']
-type FunctionalClassification =
- components['schemas']['mavedb__view_models__score_calibration__FunctionalClassification']
-
-export const NORMAL_RANGE_DEFAULT_COLOR = 'var(--color-cal-normal)'
-export const ABNORMAL_RANGE_DEFAULT_COLOR = 'var(--color-cal-abnormal)'
-export const NOT_SPECIFIED_RANGE_DEFAULT_COLOR = 'var(--color-cal-unspecified)'
-
-export const BENIGN_CRITERION = 'BS3'
-export const PATHOGENIC_CRITERION = 'PS3'
-
-export const EVIDENCE_STRENGTH_AS_POINTS = {
- VERY_STRONG: 8,
- STRONG: 4,
- MODERATE_PLUS: 3,
- MODERATE: 2,
- SUPPORTING: 1
-}
-
-export const INDETERMINATE_CALIBRATION_EVIDENCE = ['INDETERMINATE'] as const
-export const EVIDENCE_STRENGTH = EVIDENCE_STRENGTH_AS_POINTS ? Object.keys(EVIDENCE_STRENGTH_AS_POINTS) : []
-export const NORMAL_CALIBRATION_EVIDENCE = EVIDENCE_STRENGTH_AS_POINTS
- ? Object.keys(EVIDENCE_STRENGTH_AS_POINTS).map((key) => `${BENIGN_CRITERION}_${key}`)
- : []
-export const ABNORMAL_CALIBRATION_EVIDENCE = EVIDENCE_STRENGTH_AS_POINTS
- ? Object.keys(EVIDENCE_STRENGTH_AS_POINTS).map((key) => `${PATHOGENIC_CRITERION}_${key}`)
- : []
-
-export const EVIDENCE_STRENGTHS = EVIDENCE_STRENGTH_AS_POINTS
- ? Object.fromEntries(
- Object.entries(EVIDENCE_STRENGTH_AS_POINTS)
- .map(([key, value]) => [`${BENIGN_CRITERION}_${key}`, value * -1])
- .concat(
- Object.entries(EVIDENCE_STRENGTH_AS_POINTS).map(([key, value]) => [`${PATHOGENIC_CRITERION}_${key}`, value])
- )
- )
- : {}
-
-export const EVIDENCE_STRENGTHS_REVERSED = Object.fromEntries(
- Object.entries(EVIDENCE_STRENGTHS).map(([key, value]) => [value, key])
-)
/**
* Prepares a list of histogram shader configuration objects from persisted score calibration data.
@@ -54,8 +23,7 @@ export const EVIDENCE_STRENGTHS_REVERSED = Object.fromEntries(
* Each functional range in the provided calibration is converted into a HistogramShader descriptor
* containing:
* - min / max: numeric bounds either taken directly from the `range` tuple or calculated from variant scores.
- * - title: resolved from the ACMG classification evidence strength (via `EVIDENCE_STRENGTHS_REVERSED`)
- * when available; otherwise falls back to the range's `label`.
+ * - title: the range's `label`.
* - color / thresholdColor: both derived from `getRangeColor(range)` to ensure visual consistency.
* - align: fixed to `'center'` for consistent label placement.
* - startOpacity / stopOpacity: fixed opacity values (0.15 → 0.05) establishing a subtle gradient.
@@ -82,9 +50,7 @@ export const EVIDENCE_STRENGTHS_REVERSED = Object.fromEntries(
* - This function assumes that variant scores are numeric and filters out any non-numeric or NaN values.
* - The color derivation logic is centralized in `getRangeColor` to maintain consistency across the application.
*/
-export function prepareCalibrationsForHistogram(
- scoreCalibrations: components['schemas']['ScoreCalibration']
-): HistogramShader[] {
+export function prepareCalibrationsForHistogram(scoreCalibrations: ScoreCalibration): HistogramShader[] {
const preparedCalibrations: HistogramShader[] = []
if (!scoreCalibrations.functionalClassifications || scoreCalibrations.functionalClassifications.length === 0) {
@@ -114,36 +80,11 @@ export function prepareCalibrationsForHistogram(
}
/**
- * Derives the display color associated with a functional range classification.
- *
- * The color returned depends on the `classification` property of the supplied
- * `functionalClassification` object:
- * - `'normal'` => NORMAL_RANGE_DEFAULT_COLOR
- * - `'abnormal'` => ABNORMAL_RANGE_DEFAULT_COLOR
- * - `'not_specified'` => NOT_SPECIFIED_RANGE_DEFAULT_COLOR
- * - any other value => `'#000000'` (fallback)
- *
- * This utility centralizes the mapping logic so UI components can remain
- * agnostic of the underlying color constants.
- *
- * @param range The functional range whose `classification` determines the color.
- * @returns A hex color string representing the classification.
- * @example
- * const color = getRangeColor({ classification: 'normal' }); // e.g. '#3BAA5C'
- * @remarks If new classifications are introduced, extend this function to handle them explicitly.
+ * Derives the histogram range-fill color for a functional range from the shared functional-impact
+ * vocabulary (keyed by its `functionalClassification`). Falls back to black for an unknown value.
*/
-export function getClassificationColor(
- range: components['schemas']['mavedb__view_models__score_calibration__FunctionalClassification']
-): string {
- if (range.functionalClassification === 'normal') {
- return NORMAL_RANGE_DEFAULT_COLOR
- } else if (range.functionalClassification === 'abnormal') {
- return ABNORMAL_RANGE_DEFAULT_COLOR
- } else if (range.functionalClassification === 'not_specified') {
- return NOT_SPECIFIED_RANGE_DEFAULT_COLOR
- } else {
- return '#000000'
- }
+export function getClassificationColor(range: ScoreCalibrationFunctionalClassification): string {
+ return FUNCTIONAL_CLASSIFICATIONS[range.functionalClassification as FunctionalClassification]?.rangeColor ?? '#000000'
}
/**
@@ -204,7 +145,7 @@ export function shaderOverlapsBin(range: HistogramShader, bin: HistogramBin): bo
* - Upper bound check uses <= if inclusive, < if exclusive
*/
export function functionalClassificationContainsVariant(
- functionalClassification: components['schemas']['mavedb__view_models__score_calibration__FunctionalClassification'],
+ functionalClassification: ScoreCalibrationFunctionalClassification,
variantScore: number | null
): boolean {
if (variantScore === null) {
@@ -244,7 +185,7 @@ export interface ControlPlacements {
* Tally per range, keyed by the range object itself so callers may sort or chunk their ranges
* freely. Every range passed in is present, so lookups never miss.
*/
- byRange: Map
+ byRange: Map
pathogenicTotal: number
benignTotal: number
/** Controls landing in a range whose classification matches their clinical status. */
@@ -278,11 +219,11 @@ export function buildControlPlacements(
| {clinicalStatus: CalibrationControlStatus; functionalClassificationId?: number | null}[]
| null
| undefined,
- functionalClassifications: FunctionalClassification[] | null | undefined
+ functionalClassifications: ScoreCalibrationFunctionalClassification[] | null | undefined
): ControlPlacements {
const ranges = functionalClassifications ?? []
- const byRange = new Map()
- const rangeById = new Map()
+ const byRange = new Map()
+ const rangeById = new Map()
for (const range of ranges) {
byRange.set(range, {pathogenic: 0, benign: 0})
if (range.id != null) {
@@ -344,7 +285,7 @@ export function buildControlPlacements(
* @returns True if any calibration has at least one functional classification with an evidence strength
*/
export function hasPathogenicityCalibrations(
- scoreSet: {scoreCalibrations?: components['schemas']['ScoreCalibration'][] | null} | null | undefined,
+ scoreSet: {scoreCalibrations?: ScoreCalibration[] | null} | null | undefined,
{excludeResearchUseOnly = true}: {excludeResearchUseOnly?: boolean} = {}
): boolean {
const scoreCalibrations = scoreSet?.scoreCalibrations
@@ -369,7 +310,7 @@ export function hasPathogenicityCalibrations(
* @returns True if any calibration has at least one functional classification
*/
export function hasFunctionalCalibrations(
- scoreSet: {scoreCalibrations?: components['schemas']['ScoreCalibration'][] | null} | null | undefined,
+ scoreSet: {scoreCalibrations?: ScoreCalibration[] | null} | null | undefined,
{excludeResearchUseOnly = true}: {excludeResearchUseOnly?: boolean} = {}
): boolean {
const scoreCalibrations = scoreSet?.scoreCalibrations
@@ -391,21 +332,40 @@ export function hasFunctionalCalibrations(
* failing that, the one marked `investigatorProvided`. Returns null if neither exists.
*/
export function getPrimaryCalibration(
- scoreSet: {scoreCalibrations?: components['schemas']['ScoreCalibration'][] | null} | null | undefined
-): components['schemas']['ScoreCalibration'] | null {
+ scoreSet: {scoreCalibrations?: ScoreCalibration[] | null} | null | undefined
+): ScoreCalibration | null {
const calibrations = scoreSet?.scoreCalibrations
if (!calibrations || calibrations.length === 0) return null
return calibrations.find((c) => c.primary) || calibrations.find((c) => c.investigatorProvided) || null
}
+/**
+ * Picks the calibration to show by default: primary, else investigator-provided, else the first
+ * non-research-use-only, else any with functional classifications, else the first. Depends only on the
+ * score set's calibrations (not on variant scores), so it can resolve on the fast path.
+ */
+export function chooseDefaultCalibration(
+ scoreCalibrations: ScoreCalibration[] | null | undefined
+): ScoreCalibration | null {
+ if (!scoreCalibrations || scoreCalibrations.length === 0) return null
+ return (
+ scoreCalibrations.find((c) => c.primary === true) ||
+ scoreCalibrations.find((c) => c.investigatorProvided === true) ||
+ scoreCalibrations.find((c) => c.researchUseOnly !== true) ||
+ scoreCalibrations.find((c) => (c.functionalClassifications?.length ?? 0) > 0) ||
+ scoreCalibrations[0] ||
+ null
+ )
+}
+
/**
* Finds a functional classification by type (e.g. 'normal', 'abnormal') from a calibration's
* classifications list. Returns null if not found.
*/
export function findClassificationByType(
- calibration: components['schemas']['ScoreCalibration'] | null | undefined,
+ calibration: ScoreCalibration | null | undefined,
type: string
-): components['schemas']['mavedb__view_models__score_calibration__FunctionalClassification'] | null {
+): ScoreCalibrationFunctionalClassification | null {
return calibration?.functionalClassifications?.find((r) => r.functionalClassification === type) || null
}
@@ -414,7 +374,7 @@ export function findClassificationByType(
* or null if not available. Uses the specified precision (default 2).
*/
export function getClassificationOddsPath(
- calibration: components['schemas']['ScoreCalibration'] | null | undefined,
+ calibration: ScoreCalibration | null | undefined,
type: string,
precision: number = 2
): string | null {
@@ -422,25 +382,6 @@ export function getClassificationOddsPath(
return range?.oddspathsRatio != null ? range.oddspathsRatio.toFixed(precision) : null
}
-/**
- * Formats the ACMG evidence code from a functional classification's ACMG classification data.
- *
- * @param classification - A functional classification that may contain an `acmgClassification`
- * with `criterion` (e.g. "PS3", "BS3") and `evidenceStrength` (e.g. "Strong", "Moderate").
- * @returns A formatted code like "PS3_STRONG", or an empty string if evidence data is missing.
- */
-export function formatEvidenceCode(
- classification:
- | components['schemas']['mavedb__view_models__score_calibration__FunctionalClassification']
- | null
- | undefined
-): string {
- if (!classification?.acmgClassification?.evidenceStrength) return ''
- const criterion = classification.acmgClassification.criterion
- const strength = classification.acmgClassification.evidenceStrength.toUpperCase()
- return `${criterion}_${strength}`
-}
-
export type CalibrationSaveResult =
| {success: true; data: any}
| {success: false; error: 'email_required'}
diff --git a/src/lib/clingen.ts b/src/lib/clingen.ts
new file mode 100644
index 00000000..13fbb774
--- /dev/null
+++ b/src/lib/clingen.ts
@@ -0,0 +1,10 @@
+export const clingenAlleleRegistryUrl = 'https://reg.clinicalgenome.org'
+export const clingenAlleleRegistryCanonicalIdUrl = `${clingenAlleleRegistryUrl}/redmine/projects/registry/genboree_registry/by_canonicalid?canonicalid=`
+
+export function clingenAlleleUrl(clingenAlleleId: string): string {
+ return `${clingenAlleleRegistryUrl}/allele/${clingenAlleleId}`
+}
+
+export function clingenAlleleUrlFromCanonicalId(canonicalId: string): string {
+ return `${clingenAlleleRegistryCanonicalIdUrl}${canonicalId}`
+}
diff --git a/src/lib/clinical-controls.ts b/src/lib/clinical-controls.ts
deleted file mode 100644
index 3bde7d55..00000000
--- a/src/lib/clinical-controls.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-/**
- * List of ClinVar clinical significance classifications.
- *
- * Each classification contains:
- * - `name`: The full name of the clinical significance (e.g., "Pathogenic").
- * - `description`: A detailed description of the classification.
- * - `shortDescription`: An abbreviated or short label for the classification.
- *
- * These classifications are used to describe the clinical significance of variants
- * according to ClinVar standards, including categories such as "Pathogenic", "Likely pathogenic",
- * "Benign", "Likely benign", "Uncertain significance", and combinations thereof.
- *
- * NOTE: The "Conflicting" classification is dynamically generated based on the version of ClinVar,
- * as the terminology changed in 2025. The function `clinvarConflictingSignificanceClassification`
- * adjusts the label accordingly provided a ClinVar version.
- */
-export const CLINVAR_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS = [
- {
- name: 'Pathogenic',
- description: 'Pathogenic variant',
- shortDescription: 'Pathogenic'
- },
- {
- name: 'Likely pathogenic',
- description: 'Likely pathogenic variant',
- shortDescription: 'LP'
- },
- {
- name: 'Pathogenic/Likely pathogenic',
- description: 'Pathogenic/Likely pathogenic variant (in different submissions)',
- shortDescription: 'Path/LP (both)'
- },
- {
- name: 'Benign',
- description: 'Benign variant',
- shortDescription: 'Benign'
- },
- {
- name: 'Likely benign',
- description: 'Likely benign variant',
- shortDescription: 'LB'
- },
- {
- name: 'Benign/Likely benign',
- description: 'Benign/Likely benign variant (in different submissions)',
- shortDescription: 'B/LB (both)'
- },
- {
- name: 'Uncertain significance',
- description: 'Variant of uncertain significance',
- shortDescription: 'VUS'
- }
-]
-
-export const BENIGN_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS = ['Likely benign', 'Benign', 'Benign/Likely benign']
-
-export const PATHOGENIC_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS = [
- 'Likely pathogenic',
- 'Pathogenic',
- 'Pathogenic/Likely pathogenic'
-]
-
-export const CONFLICTING_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS = [
- 'Conflicting interpretations of pathogenicity',
- 'Conflicting classifications of pathogenicity'
-]
-
-export const CLINVAR_REVIEW_STATUS_STARS: { [status: string]: number } = {
- 'no assertion criteria provided': 0,
- 'criteria provided, conflicting interpretations': 1,
- 'criteria provided, conflicting classifications': 1,
- 'criteria provided, single submitter': 1,
- 'criteria provided, multiple submitters, no conflicts': 2,
- 'reviewed by expert panel': 3
-}
-
-export const DEFAULT_CLNSIG_FIELD = 'clinicalSignificance'
-export const DEFAULT_CLNREVSTAT_FIELD = 'clinicalReviewStatus'
-
-export const DEFAULT_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS = [
- 'Likely pathogenic',
- 'Pathogenic',
- 'Pathogenic/Likely pathogenic',
- 'Likely benign',
- 'Benign',
- 'Benign/Likely benign'
-]
-export const DEFAULT_MIN_STAR_RATING = 1
-
-export const DEFAULT_CLINICAL_CONTROL_DB = 'ClinVar'
-export const DEFAULT_CLINICAL_CONTROL_VERSION = '01_2025'
-
-export interface ClinicalControlOption {
- dbName: string
- availableVersions: string[]
-}
-
-export interface ClinicalControl {
- dbName: string
- dbVersion: string
- dbIdentifier: string
- clnsigField: string
- clnrevstatField: string
- geneSymbol: string
- modificationDate: Date
- creationDate: Date
- mappedVariants: Array
-}
-
-/**
- * Returns an array of ClinVar clinical significance classifications,
- * appending a "Conflicting" classification with a description that
- * depends on the provided version string. ClinVar changed the verbiage
- * for conflicting classifications in 2025, so this function adjusts
- * the label based on the version.
- *
- * If the version (expected in the format "prefix_YYYY") is greater than 2024,
- * the "Conflicting classifications of pathogenicity" label is used.
- * Otherwise, "Conflicting interpretations of pathogenicity" is used.
- *
- * @param version - The ClinVar version string, expected to contain a year after an underscore (e.g., "v_2023").
- * @returns An array of clinical significance classification objects, including the appropriate "Conflicting" classification.
- */
-export function clinvarClinicalSignificanceClassifications(
- version: string
-): typeof CLINVAR_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS {
- return [
- ...CLINVAR_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS,
- clinvarConflictingSignificanceClassificationForVersion(version),
- ]
-}
-
-/**
- * Returns the appropriate ClinVar conflicting significance classification object for a given version.
- *
- * Depending on the version string (expected in the format "prefix_YYYY"), this function returns
- * an object containing the name, description, and shortDescription for the conflicting significance
- * classification. For versions after 2024, the naming reflects updated ClinVar terminology.
- *
- * @param version - The version string, expected to contain a year after an underscore (e.g., "clinvar_2025").
- * @returns An object with `name`, `description`, and `shortDescription` fields describing the conflicting classification.
- */
-export function clinvarConflictingSignificanceClassificationForVersion(version: string): {
- name: string
- description: string
- shortDescription: string
-} {
- if (Number(version.split('_')[1]) > 2024) {
- return {
- name: 'Conflicting classifications of pathogenicity',
- description: 'Variant with conflicting classifications of pathogenicity',
- shortDescription: 'Conflicting'
- }
- } else {
- return {
- name: 'Conflicting interpretations of pathogenicity',
- description: 'Variant with conflicting interpretations of pathogenicity',
- shortDescription: 'Conflicting'
- }
- }
-}
-
-/**
- * Returns the appropriate label for conflicting clinical significance series based on the provided version string.
- *
- * The label changes depending on the numeric value after the underscore in the version string:
- * - If the numeric part is greater than 2024, returns "Conflicting classifications".
- * - Otherwise, returns "Conflicting interpretations".
- *
- * @param version - The version string in the format "prefix_number" (e.g., "v_2025").
- * @returns The label for conflicting clinical significance series.
- */
-export function conflictingClinicalSignificanceSeriesLabelForVersion(version: string): string {
- if (Number(version.split('_')[1]) > 2024) {
- return 'Conflicting classifications'
- } else {
- return 'Conflicting interpretations'
- }
-}
diff --git a/src/lib/clinvar-control-placement.test.ts b/src/lib/clinvar-control-placement.test.ts
new file mode 100644
index 00000000..51a484d3
--- /dev/null
+++ b/src/lib/clinvar-control-placement.test.ts
@@ -0,0 +1,447 @@
+import {describe, expect, test} from 'vitest'
+
+import {
+ reduceControlPlacement,
+ resolveClinvarHeadline,
+ type ClinvarControlPlacement,
+ type ControlLink,
+ type UsableControlPlacement
+} from '@/lib/clinvar-control-placement'
+import type {MeasurementClinvarRecord} from '@/lib/clinvar-controls'
+
+// Review statuses and their star ratings (mirrors CLINVAR_REVIEW_STATUS_STARS), so the representative-pick
+// and per-classification-status tests are explicit about stars.
+const NO_CRITERIA = 'no assertion criteria provided' // 0★
+const ONE_STAR = 'criteria provided, single submitter' // 1★
+const TWO_STAR = 'criteria provided, multiple submitters, no conflicts' // 2★
+const THREE_STAR = 'reviewed by expert panel' // 3★
+
+const ASSAY = 'assayed-level-digest'
+const SIB = 'sibling-digest'
+const SIB2 = 'sibling-digest-2'
+
+// Significances (exact strings the P/LP and B/LB membership lists key on).
+const P = 'Pathogenic'
+const LP = 'Likely pathogenic'
+const PLP = 'Pathogenic/Likely pathogenic'
+const B = 'Benign'
+const LB = 'Likely benign'
+const BLB = 'Benign/Likely benign'
+const VUS = 'Uncertain significance'
+const CONFLICTING = 'Conflicting classifications of pathogenicity'
+
+function link(significance: string, alleleDigest?: string, reviewStatus: string = ONE_STAR): ControlLink {
+ return {significance, reviewStatus, alleleDigest, dbIdentifier: `${significance}@${alleleDigest ?? '?'}`}
+}
+
+/** The set of distinct significances in a placement (order-independent assertions). */
+const sigs = (p: ClinvarControlPlacement) => p.classifications.map((c) => c.significance).sort()
+
+/** Narrow to a usable placement — hard discordance has no representative, so asserting one there is a bug. */
+function usable(p: ClinvarControlPlacement): UsableControlPlacement {
+ if (p.discordance === 'hard') throw new Error('expected a usable placement, got hard discordance')
+ return p
+}
+
+describe('reduceControlPlacement — divergence fold', () => {
+ test('no controls reach the variant → null', () => {
+ expect(reduceControlPlacement([], ASSAY)).toBeNull()
+ })
+
+ describe('single direct call at the assayed level (projected = false)', () => {
+ test.each([
+ [P, {directional: true}],
+ [LP, {directional: true}],
+ [PLP, {directional: true}],
+ [B, {directional: true}],
+ [LB, {directional: true}],
+ [BLB, {directional: true}],
+ [VUS, {directional: false}],
+ // ClinVar's own aggregate "conflicting" value is neither pathogenic- nor benign-side, so it is not
+ // directional and does not by itself trigger any discordance.
+ [CONFLICTING, {directional: false}]
+ ])('%s → directional flag, no discordance, not projected', (significance, expected) => {
+ const p = reduceControlPlacement([link(significance, ASSAY)], ASSAY)!
+ expect(usable(p).directional).toBe(expected.directional)
+ expect(p.discordance).toBe('none')
+ expect(p.projected).toBe(false)
+ expect(sigs(p)).toEqual([significance])
+ expect(usable(p).clinicalSignificance).toBe(significance)
+ })
+ })
+
+ describe('precedence — the assayed level wins; the encodings are ignored', () => {
+ test('a lone assayed VUS blocks an encoding`s LP (any assayed call stops the fall-through)', () => {
+ const p = reduceControlPlacement([link(VUS, ASSAY), link(LP, SIB)], ASSAY)!
+ expect(sigs(p)).toEqual([VUS])
+ expect(usable(p).directional).toBe(false)
+ expect(p.projected).toBe(false)
+ })
+
+ test('an assayed directional call is not overridden into hard discordance by a discordant encoding', () => {
+ const p = reduceControlPlacement([link(P, ASSAY), link(B, SIB)], ASSAY)!
+ expect(sigs(p)).toEqual([P])
+ expect(p.discordance).toBe('none')
+ expect(p.projected).toBe(false)
+ })
+
+ test('multiple assayed-level calls are all consulted (assayed-level discordance is still real)', () => {
+ // Two ClinVar submissions on the *same* assayed allele that disagree on direction → hard.
+ const p = reduceControlPlacement([link(P, ASSAY), link(B, ASSAY), link(LP, SIB)], ASSAY)!
+ expect(p.discordance).toBe('hard')
+ expect(p.projected).toBe(false)
+ // The encoding's LP is not consulted — only the two assayed-level calls.
+ expect(sigs(p)).toEqual([B, P])
+ })
+ })
+
+ describe('fall-through to the encodings (measured allele unannotated → projected = true)', () => {
+ test('single encoding call → placed on its side, flagged projected', () => {
+ const p = reduceControlPlacement([link(LB, SIB)], ASSAY)!
+ expect(sigs(p)).toEqual([LB])
+ expect(usable(p).directional).toBe(true)
+ expect(p.discordance).toBe('none')
+ expect(p.projected).toBe(true)
+ })
+
+ describe('hard discordance (both directions present) → discordance = hard', () => {
+ test.each([
+ ['P + B', [P, B]],
+ ['P + LB', [P, LB]],
+ ['LP + B', [LP, B]],
+ ['LP + LB', [LP, LB]],
+ ['PLP + BLB', [PLP, BLB]],
+ ['P + B + VUS (VUS does not rescue)', [P, B, VUS]]
+ ])('%s → hard', (_label, significances) => {
+ const links = significances.map((s, i) => link(s, i === 0 ? SIB : SIB2))
+ const p = reduceControlPlacement(links, ASSAY)!
+ expect(p.discordance).toBe('hard')
+ expect(p.projected).toBe(true)
+ })
+
+ test('carries the full set to reconstruct, but no representative — no fake single winner', () => {
+ const p = reduceControlPlacement([link(P, SIB, ONE_STAR), link(B, SIB2, TWO_STAR)], ASSAY)!
+ expect(p.discordance).toBe('hard')
+ // The conflicting calls are still enumerable by any surface.
+ expect(sigs(p)).toEqual([B, P])
+ // But there is physically no winner: the representative fields are absent from the value (and type).
+ expect('clinicalSignificance' in p).toBe(false)
+ expect('alleleDigest' in p).toBe(false)
+ expect('directional' in p).toBe(false)
+ })
+ })
+
+ describe('concordant (≥2 distinct calls in one direction, no uncertain record) → discordance = concordant', () => {
+ test('same-side {P, LP} → both calls carried, pathogenic, concordant', () => {
+ const p = reduceControlPlacement([link(P, SIB), link(LP, SIB2)], ASSAY)!
+ expect(p.discordance).toBe('concordant')
+ expect(usable(p).directional).toBe(true)
+ expect(sigs(p)).toEqual([LP, P])
+ })
+
+ test('same-side {B, LB} → benign, concordant', () => {
+ const p = reduceControlPlacement([link(B, SIB), link(LB, SIB2)], ASSAY)!
+ expect(p.discordance).toBe('concordant')
+ expect(sigs(p)).toEqual([B, LB])
+ })
+ })
+
+ describe('soft conflict (a directional lean + an uncertain record) → discordance = soft', () => {
+ // The VUS widening: a directional lean beside a VUS is now a *soft conflict* (was previously `none`).
+ // The lean still represents and `directional` stays true; the histogram folds it into the directional
+ // series only while its soft-conflicts toggle is on.
+ test('directional + VUS {LP, VUS} → soft, lean represents', () => {
+ const p = reduceControlPlacement([link(LP, SIB), link(VUS, SIB2)], ASSAY)!
+ expect(p.discordance).toBe('soft')
+ expect(usable(p).directional).toBe(true)
+ expect(usable(p).clinicalSignificance).toBe(LP)
+ expect(sigs(p)).toEqual([LP, VUS])
+ })
+
+ test('benign + VUS {B, VUS} → soft, benign lean represents', () => {
+ const p = reduceControlPlacement([link(B, SIB), link(VUS, SIB2)], ASSAY)!
+ expect(p.discordance).toBe('soft')
+ expect(usable(p).directional).toBe(true)
+ expect(usable(p).clinicalSignificance).toBe(B)
+ expect(sigs(p)).toEqual([B, VUS])
+ })
+
+ // A directional lean beside a ClinVar-*Conflicting* record is the same soft conflict (folds in what was
+ // the retired `contested` value).
+ test('directional + Conflicting {P, CONFLICTING} → soft, directional lean represents', () => {
+ const p = reduceControlPlacement([link(P, SIB), link(CONFLICTING, SIB2)], ASSAY)!
+ expect(p.discordance).toBe('soft')
+ expect(usable(p).directional).toBe(true)
+ expect(usable(p).clinicalSignificance).toBe(P)
+ expect(sigs(p)).toEqual([CONFLICTING, P].sort())
+ })
+
+ test('same-direction multiplicity + an uncertain record → soft outranks concordant', () => {
+ const p = reduceControlPlacement([link(P, SIB), link(LP, SIB2), link(CONFLICTING, SIB)], ASSAY)!
+ expect(p.discordance).toBe('soft')
+ expect(usable(p).directional).toBe(true)
+ })
+ })
+
+ test('VUS-only encodings → not directional (lands in the VUS series)', () => {
+ const p = reduceControlPlacement([link(VUS, SIB), link(VUS, SIB2)], ASSAY)!
+ expect(usable(p).directional).toBe(false)
+ expect(p.discordance).toBe('none')
+ // Duplicate significances collapse to one classification.
+ expect(sigs(p)).toEqual([VUS])
+ })
+ })
+
+ describe('classifications set — distinct, order-preserving, status-carrying', () => {
+ test('duplicate significances across encodings collapse to one classification', () => {
+ const p = reduceControlPlacement([link(P, SIB), link(P, SIB2)], ASSAY)!
+ expect(p.classifications).toHaveLength(1)
+ expect(p.classifications[0].significance).toBe(P)
+ })
+
+ test('each classification keeps its own review status (for the downstream ≥minStar gate)', () => {
+ const p = reduceControlPlacement([link(P, SIB, TWO_STAR), link(LP, SIB2, NO_CRITERIA)], ASSAY)!
+ const byName = Object.fromEntries(p.classifications.map((c) => [c.significance, c.reviewStatus]))
+ expect(byName[P]).toBe(TWO_STAR)
+ expect(byName[LP]).toBe(NO_CRITERIA)
+ })
+
+ test('first-seen order is preserved', () => {
+ const p = reduceControlPlacement([link(LP, SIB), link(P, SIB2)], ASSAY)!
+ expect(p.classifications.map((c) => c.significance)).toEqual([LP, P])
+ })
+ })
+
+ describe('representative pick (for one-label surfaces: search dot, notables, tooltip)', () => {
+ test('a directional call is preferred over a higher-star VUS', () => {
+ const p = reduceControlPlacement([link(LP, SIB, ONE_STAR), link(VUS, SIB2, THREE_STAR)], ASSAY)!
+ expect(usable(p).clinicalSignificance).toBe(LP)
+ })
+
+ test('among directional calls, the highest-star one represents', () => {
+ const p = usable(reduceControlPlacement([link(LP, SIB, ONE_STAR), link(P, SIB2, TWO_STAR)], ASSAY)!)
+ expect(p.clinicalSignificance).toBe(P)
+ expect(p.clinicalReviewStatus).toBe(TWO_STAR)
+ })
+
+ test('VUS-only → the VUS represents', () => {
+ const p = reduceControlPlacement([link(VUS, SIB, TWO_STAR)], ASSAY)!
+ expect(usable(p).clinicalSignificance).toBe(VUS)
+ })
+ })
+
+ describe('representative allele digest (for resolving the winning call on a per-variant surface)', () => {
+ test('carries the digest of the representative call', () => {
+ const p = reduceControlPlacement([link(LP, 's1', ONE_STAR), link(P, 's2', TWO_STAR)], 'assay')!
+ // Representative is the highest-star directional (P on s2), so its digest surfaces.
+ expect(usable(p).alleleDigest).toBe('s2')
+ })
+
+ test('a direct assayed-level call surfaces the assayed digest', () => {
+ const p = reduceControlPlacement([link(P, ASSAY), link(LP, SIB)], ASSAY)!
+ expect(usable(p).alleleDigest).toBe(ASSAY)
+ expect(p.projected).toBe(false)
+ })
+ })
+
+ describe('none (a single call, or uncertain-only records) → discordance = none', () => {
+ test('a lone Conflicting record (no directional lean) is not a soft conflict', () => {
+ const p = reduceControlPlacement([link(CONFLICTING, SIB)], ASSAY)!
+ expect(p.discordance).toBe('none')
+ expect(usable(p).directional).toBe(false)
+ expect(usable(p).clinicalSignificance).toBe(CONFLICTING)
+ })
+
+ test('uncertain-only {VUS, Conflicting} → none (no directional lean to conflict with)', () => {
+ const p = reduceControlPlacement([link(VUS, SIB), link(CONFLICTING, SIB2)], ASSAY)!
+ expect(p.discordance).toBe('none')
+ expect(usable(p).directional).toBe(false)
+ expect(sigs(p)).toEqual([CONFLICTING, VUS].sort())
+ })
+
+ test('opposite directions outrank an uncertain record → hard, not soft', () => {
+ const p = reduceControlPlacement([link(P, SIB), link(B, SIB2), link(CONFLICTING, SIB2)], ASSAY)!
+ expect(p.discordance).toBe('hard')
+ })
+ })
+
+ describe('unclassified ClinVar values ("-"/empty) are not treated as calls', () => {
+ test('a dash-only set → null (not a control, not a call)', () => {
+ expect(reduceControlPlacement([link('-', ASSAY), link('-', SIB)], ASSAY)).toBeNull()
+ })
+
+ test('a dash on the assayed allele does NOT block fall-through to a real enbling', () => {
+ // The `-` (no germline classification) is filtered, so the assayed allele reads as unannotated and
+ // the real encoding's LP wins — the fall-through fires, projected.
+ const p = reduceControlPlacement([link('-', ASSAY), link(LP, SIB)], ASSAY)!
+ expect(sigs(p)).toEqual([LP])
+ expect(p.projected).toBe(true)
+ })
+
+ test('empty/whitespace/multi-dash significances are ignored', () => {
+ const p = reduceControlPlacement([link('', SIB), link('--', SIB2), link(P, SIB2)], ASSAY)!
+ expect(sigs(p)).toEqual([P])
+ })
+ })
+
+ describe('unknown assayed-level digest — provenance not claimed', () => {
+ test.each([[null], [undefined]])('digest = %s → all links win, projected = false', (digest) => {
+ const p = reduceControlPlacement([link(P, SIB), link(LP, SIB2)], digest)!
+ // With no assayed digest we cannot say a call is "on the measured allele", so we do NOT flag it as
+ // projected (which would wrongly imply we know it is about a *different* level).
+ expect(p.projected).toBe(false)
+ expect(sigs(p)).toEqual([LP, P])
+ })
+ })
+})
+
+/** A resolved record for one allele — the walk's output that the headline projects from. */
+function rec(significance: string, digest: string, reviewStatus: string = ONE_STAR): MeasurementClinvarRecord {
+ return {
+ digest,
+ onAssayed: digest === ASSAY,
+ hgvs: null,
+ classified: !!significance.trim() && !/^-+$/.test(significance.trim()),
+ clinvar: {
+ clinicalSignificance: significance,
+ clinicalReviewStatus: reviewStatus,
+ clinvarVariationId: null,
+ clinvarAlleleId: `${significance}@${digest}`,
+ dbVersion: '03_2024'
+ }
+ }
+}
+
+describe('reduceControlPlacement — assay-level gating of projection', () => {
+ test('protein level projects an encoding`s call when the measured allele has none', () => {
+ const p = reduceControlPlacement([link(P, SIB)], ASSAY, 'protein')
+ expect(p?.projected).toBe(true)
+ expect(usable(p!).clinicalSignificance).toBe(P)
+ })
+
+ test('nucleotide level does not project — no direct record → null', () => {
+ expect(reduceControlPlacement([link(P, SIB)], ASSAY, 'cdna')).toBeNull()
+ expect(reduceControlPlacement([link(P, SIB)], ASSAY, 'genomic')).toBeNull()
+ })
+
+ test('nucleotide level still honors the measured allele`s own direct call', () => {
+ const p = reduceControlPlacement([link(P, ASSAY), link(VUS, SIB)], ASSAY, 'cdna')
+ expect(usable(p!).clinicalSignificance).toBe(P)
+ expect(usable(p!).projected).toBe(false)
+ })
+
+ test('unknown level preserves the prior behavior — projects', () => {
+ expect(reduceControlPlacement([link(P, SIB)], ASSAY)?.projected).toBe(true)
+ })
+
+ test('a subject digest *set* (a projection pair) counts a record on either representation as direct', () => {
+ // The physical allele is stored as ASSAY (coding) + SIB (its genomic projection); ClinVar linked the record to
+ // the projection. Anchoring on both digests keeps it a direct call, not a projection off another allele.
+ const p = reduceControlPlacement([link(P, SIB)], [ASSAY, SIB], 'cdna')
+ expect(usable(p!).clinicalSignificance).toBe(P)
+ expect(usable(p!).projected).toBe(false)
+ })
+})
+
+describe('resolveClinvarHeadline — the display decision', () => {
+ test('no records → none', () => {
+ expect(resolveClinvarHeadline([], ASSAY)).toEqual({kind: 'none'})
+ })
+
+ test('nucleotide level, no direct record but classified encodings → kind "absent"', () => {
+ expect(resolveClinvarHeadline([rec(P, SIB)], ASSAY, 'cdna')).toEqual({kind: 'absent'})
+ expect(resolveClinvarHeadline([rec(P, SIB), rec(VUS, SIB2)], ASSAY, 'genomic')).toEqual({kind: 'absent'})
+ })
+
+ test('protein level, no direct record → projects to kind "call"', () => {
+ expect(resolveClinvarHeadline([rec(P, SIB)], ASSAY, 'protein').kind).toBe('call')
+ })
+
+ test('measured allele has no record, only germline-less related records → absent (not an encoding`s presence)', () => {
+ // The measured variant has no record of its own; an encoding`s `-` must not be shown as this variant`s state.
+ expect(resolveClinvarHeadline([rec('-', SIB)], ASSAY, 'cdna')).toEqual({kind: 'absent'})
+ expect(resolveClinvarHeadline([rec('-', SIB)], ASSAY, 'protein')).toEqual({kind: 'absent'})
+ })
+
+ test('nucleotide level, measured allele carries a `-` record → presence, not absent', () => {
+ const headline = resolveClinvarHeadline([rec('-', ASSAY), rec(P, SIB)], ASSAY, 'cdna')
+ expect(headline.kind).toBe('presence')
+ if (headline.kind !== 'presence') throw new Error('expected presence')
+ expect(headline.record.onAssayed).toBe(true)
+ })
+
+ test('nucleotide level, measured allele has its own call → kind "call" (direct, not projected)', () => {
+ const headline = resolveClinvarHeadline([rec(P, ASSAY), rec(VUS, SIB)], ASSAY, 'cdna')
+ expect(headline.kind).toBe('call')
+ if (headline.kind !== 'call') throw new Error('expected a call')
+ expect(headline.placement.projected).toBe(false)
+ })
+
+ test('a usable call → kind "call", carrying the representative record and placement', () => {
+ const headline = resolveClinvarHeadline([rec(P, ASSAY), rec(VUS, SIB)], ASSAY)
+ expect(headline.kind).toBe('call')
+ if (headline.kind !== 'call') throw new Error('expected a call')
+ expect(headline.clinvar.clinicalSignificance).toBe(P)
+ expect(headline.placement.discordance).not.toBe('hard')
+ })
+
+ test('opposite-direction calls in the winning set → kind "conflicting"', () => {
+ const headline = resolveClinvarHeadline([rec(P, ASSAY), rec(B, ASSAY)], ASSAY)
+ expect(headline.kind).toBe('conflicting')
+ })
+
+ test('a directional lean + a Conflicting encoding → kind "call", note "soft-conflicting"', () => {
+ const headline = resolveClinvarHeadline([rec(P, SIB), rec(CONFLICTING, SIB2)], ASSAY)
+ expect(headline.kind).toBe('call')
+ if (headline.kind !== 'call') throw new Error('expected a call')
+ // The lean shows, and the note flags ClinVar's own conflict verdict for the surface to render.
+ expect(headline.clinvar.clinicalSignificance).toBe(P)
+ expect(headline.placement.discordance).toBe('soft')
+ expect(headline.note).toBe('soft-conflicting')
+ })
+
+ test('a directional lean + a VUS encoding → kind "call", note "soft-vus"', () => {
+ const headline = resolveClinvarHeadline([rec(LP, SIB), rec(VUS, SIB2)], ASSAY)
+ expect(headline.kind).toBe('call')
+ if (headline.kind !== 'call') throw new Error('expected a call')
+ expect(headline.clinvar.clinicalSignificance).toBe(LP)
+ expect(headline.placement.discordance).toBe('soft')
+ expect(headline.note).toBe('soft-vus')
+ })
+
+ test('≥2 agreeing same-direction records → kind "call", note "concordant"', () => {
+ const headline = resolveClinvarHeadline([rec(P, SIB), rec(LP, SIB2)], ASSAY)
+ expect(headline.kind).toBe('call')
+ if (headline.kind !== 'call') throw new Error('expected a call')
+ expect(headline.placement.discordance).toBe('concordant')
+ expect(headline.note).toBe('concordant')
+ })
+
+ test('a lone unambiguous call → kind "call", note "none"', () => {
+ const headline = resolveClinvarHeadline([rec(P, ASSAY)], ASSAY)
+ expect(headline.kind).toBe('call')
+ if (headline.kind !== 'call') throw new Error('expected a call')
+ expect(headline.note).toBe('none')
+ })
+
+ test('only a `-` record → kind "presence" (not dropped; precedence, not the fold)', () => {
+ const headline = resolveClinvarHeadline([rec('-', ASSAY)], ASSAY)
+ expect(headline.kind).toBe('presence')
+ if (headline.kind !== 'presence') throw new Error('expected presence')
+ expect(headline.record.digest).toBe(ASSAY)
+ })
+
+ test('a real call outranks a co-occurring `-` — presence never shadows a classification', () => {
+ const headline = resolveClinvarHeadline([rec('-', ASSAY), rec(LP, SIB)], ASSAY)
+ expect(headline.kind).toBe('call')
+ if (headline.kind !== 'call') throw new Error('expected a call')
+ expect(headline.clinvar.clinicalSignificance).toBe(LP)
+ })
+
+ test('presence prefers the measured allele`s own `-` record over an encoding`s', () => {
+ const headline = resolveClinvarHeadline([rec('-', SIB), rec('-', ASSAY)], ASSAY)
+ expect(headline.kind).toBe('presence')
+ if (headline.kind !== 'presence') throw new Error('expected presence')
+ expect(headline.record.onAssayed).toBe(true)
+ })
+})
diff --git a/src/lib/clinvar-control-placement.ts b/src/lib/clinvar-control-placement.ts
new file mode 100644
index 00000000..d72bd528
--- /dev/null
+++ b/src/lib/clinvar-control-placement.ts
@@ -0,0 +1,281 @@
+/**
+ * @fileoverview
+ * The ClinVar clinical-control divergence fold.
+ *
+ * For an AA-resolution score set one measured protein change is encoded by several DNA variants, whose
+ * ClinVar classifications can disagree. This module reduces the set of controls that reach a single
+ * variant to one placement the histogram can bin off — applying source precedence (the assayed-level
+ * allele's own call wins; the encodings are only a fallback) and grading how the winning calls
+ * disagree ({@link Discordance}). Directional-over-uncertain is precedence, not discordance.
+ */
+
+import {
+ BENIGN_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS,
+ CLINVAR_REVIEW_STATUS_STARS,
+ CONFLICTING_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS,
+ DEFAULT_CLNREVSTAT_FIELD,
+ DEFAULT_CLNSIG_FIELD,
+ isClassifiedSignificance,
+ type MeasurementClinvarRecord,
+ PATHOGENIC_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS
+} from '@/lib/clinvar-controls'
+import {type SubjectDigest, toSubjectDigestSet} from '@/lib/annotation-subject'
+import type {components} from '@/schema/openapi'
+
+type ClinvarAnnotation = components['schemas']['ClinvarAnnotation']
+type SequenceLevel = components['schemas']['SequenceLevel']
+
+// ── PLACEMENT: reduce one variant's ClinVar controls to a single call ──
+
+/**
+ * How the ClinVar calls reaching a measurement disagree, ordered by placement difficulty.
+ * - `none` — a single call, or uncertain-only records (a lone VUS, VUS + Conflicting). Nothing to reconcile.
+ * - `concordant` — ≥2 distinct calls in a *single* direction (e.g. Likely benign + Benign); usable, and we
+ * picked a representative via star tiebreak. Purely informational: the direction is not in doubt.
+ * - `soft` — a *soft conflict*: one directional lean co-occurs with an uncertain record (a VUS or a
+ * ClinVar-*Conflicting* call). Usable (the directional call is the representative) but flagged because the
+ * uncertain record hints the direction may not be settled. Places as its directional lean.
+ * - `hard` — opposing directional *assertions* (P/LP and B/LB); the projection premise is broken, so there is
+ * no usable representative. Categorical — confidence within a direction never changes this.
+ */
+export type Discordance = 'none' | 'concordant' | 'soft' | 'hard'
+
+/** A distinct ClinVar call in a variant's winning set. */
+export interface ControlClassification {
+ significance: string
+ reviewStatus: string
+}
+
+/** One ClinVar control reaching a variant, tagged with the digest of the allele it annotates. */
+export interface ControlLink extends ControlClassification {
+ alleleDigest?: string | null
+ dbIdentifier?: string
+}
+
+/** Fields present on every placement, usable or not — enough to reconstruct the winning set behind the fold. */
+interface BaseControlPlacement {
+ /** The winning set's distinct calls (assayed-level controls if any, else the encodings). */
+ classifications: ControlClassification[]
+ /**
+ * True when the winning set came from the **encodings**. The measured allele itself had no
+ * ClinVar record, so this classification is about a *related* variant at a different level, not the
+ * entity we assayed.
+ */
+ projected: boolean
+}
+
+/**
+ * A hard-discordant placement: the winning set holds both a P/LP and a B/LB call, so there is *physically*
+ * no representative — no single call can stand for a contradiction. Carries only the set (so a surface can
+ * still list the conflicting calls); the representative fields are absent from the type to avoid accidental
+ * misuse.
+ */
+export interface HardDiscordantPlacement extends BaseControlPlacement {
+ discordance: 'hard'
+}
+
+/** A usable placement (`none`/`concordant`/`soft`): a single representative call the one-label surfaces read. */
+export interface UsableControlPlacement extends BaseControlPlacement {
+ discordance: 'none' | 'concordant' | 'soft'
+ /** Representative single call (directional-preferred, then highest-star) for one-label surfaces. */
+ [DEFAULT_CLNSIG_FIELD]: string
+ [DEFAULT_CLNREVSTAT_FIELD]: string
+ dbIdentifier?: string
+ /** Whether any confident directional (P/LP or B/LB) call is present — directional dominates VUS. */
+ directional: boolean
+ /** VRS digest of the representative call's allele. Undefined only when the winning links carried no digest. */
+ alleleDigest?: string | null
+}
+
+/**
+ * The reduced result for one variant — what the histogram and per-variant surfaces read. A discriminated
+ * union on `discordance`: narrow to a {@link UsableControlPlacement} (or check `discordance !== 'hard'`)
+ * before reading the representative call.
+ */
+export type ClinvarControlPlacement = HardDiscordantPlacement | UsableControlPlacement
+
+const isPathogenic = (significance: string) => PATHOGENIC_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS.includes(significance)
+const isBenign = (significance: string) => BENIGN_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS.includes(significance)
+const isDirectional = (significance: string) => isPathogenic(significance) || isBenign(significance)
+const isConflicting = (significance: string) => CONFLICTING_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS.includes(significance)
+const starsOf = (reviewStatus: string) => CLINVAR_REVIEW_STATUS_STARS[reviewStatus] ?? -1
+
+/**
+ * Reduce the ClinVar controls reaching one variant to a single {@link ClinvarControlPlacement}.
+ *
+ * **Precedence:** if any control annotates the variant's own subject allele (`alleleDigest` ∈ the subject
+ * digest set — the measured/page allele, including its projection), those controls are the winning set and
+ * the encodings are ignored — a direct call on the assayed entity is not diluted by the fan-out.
+ *
+ * **Falling back to the encodings is level-gated.** It's only legitimate at *protein* assay level, where
+ * ClinVar (a nucleotide resource) cannot annotate the measured allele itself, so the encodings are the only
+ * available signal. At nucleotide level (`cdna`/`genomic`) the measured variant *can* carry its own record,
+ * so its absence is real information: we do **not** borrow an encoding's call (that would both overreach and
+ * double-count — the encoding is its own scored row). With no direct record at nucleotide level, this
+ * returns `null`.
+ *
+ * The winning set is then graded for {@link Discordance} and reduced to a representative call
+ * (directional-preferred, then highest star). `directional` records whether any confident P/LP or B/LB call
+ * is present, so callers can drop VUS when a directional call dominates.
+ *
+ * Returns `null` when no *classified* control reaches the variant.
+ */
+export function reduceControlPlacement(
+ links: ControlLink[],
+ subject: SubjectDigest,
+ assayLevel?: SequenceLevel | null
+): ClinvarControlPlacement | null {
+ // Drop ClinVar "no classification" placeholders (a literal `-`/empty) up front, so they neither become a
+ // call nor — as a `-` on the assayed allele — block fall-through to a real encoding.
+ const classified = links.filter((link) => isClassifiedSignificance(link.significance))
+ if (classified.length === 0) {
+ return null
+ }
+
+ const subjectSet = toSubjectDigestSet(subject)
+ const assayedLevel =
+ subjectSet.size > 0 ? classified.filter((link) => link.alleleDigest && subjectSet.has(link.alleleDigest)) : []
+ // Only project at protein level (or when the level is unknown, to avoid silently dropping controls);
+ // at nucleotide level a missing direct record is final.
+ const canProject = assayLevel !== 'cdna' && assayLevel !== 'genomic'
+ const winning = assayedLevel.length > 0 ? assayedLevel : canProject ? classified : []
+ if (winning.length === 0) {
+ return null
+ }
+ // Fell through to the encodings — but only *claim* projection when the subject digest is known; an absent
+ // digest means unknown provenance, which we don't surface as "related variant" (avoids mislabeling).
+ const projected = subjectSet.size > 0 && assayedLevel.length === 0
+
+ // Distinct calls, preserving first-seen order.
+ const seen = new Set()
+ const classifications: ControlClassification[] = []
+ for (const link of winning) {
+ if (!seen.has(link.significance)) {
+ seen.add(link.significance)
+ classifications.push({significance: link.significance, reviewStatus: link.reviewStatus})
+ }
+ }
+
+ const pathogenicCalls = classifications.filter((c) => isPathogenic(c.significance))
+ const benignCalls = classifications.filter((c) => isBenign(c.significance))
+ const directional = pathogenicCalls.length > 0 || benignCalls.length > 0
+ // Any non-directional call is "uncertain" — a VUS *or* ClinVar's own aggregate Conflicting value.
+ const uncertainPresent = classifications.some((c) => !isDirectional(c.significance))
+
+ // Grade per Discordance: opposing directions is hard; a single direction plus an uncertain record is soft;
+ // ≥2 same-direction calls alone is concordant.
+ if (pathogenicCalls.length > 0 && benignCalls.length > 0) {
+ // Hard: opposing directional assertions break the projection premise, so there is no single winning call.
+ // No representative is computed, and the type withholds those fields so no surface can read a fake one.
+ return {discordance: 'hard', classifications, projected}
+ }
+ // soft > concordant > none.
+ const discordance: 'none' | 'concordant' | 'soft' =
+ directional && uncertainPresent
+ ? 'soft'
+ : pathogenicCalls.length > 1 || benignCalls.length > 1
+ ? 'concordant'
+ : 'none'
+
+ // Representative for one-label surfaces (search dot, notables, tooltip): a directional call is more
+ // informative than a co-occurring VUS, so prefer directional calls when present, then highest star.
+ const candidates = directional ? winning.filter((link) => isDirectional(link.significance)) : winning
+ const representative = candidates.reduce((best, link) =>
+ starsOf(link.reviewStatus) > starsOf(best.reviewStatus) ? link : best
+ )
+
+ return {
+ [DEFAULT_CLNSIG_FIELD]: representative.significance,
+ [DEFAULT_CLNREVSTAT_FIELD]: representative.reviewStatus,
+ dbIdentifier: representative.dbIdentifier,
+ classifications,
+ discordance,
+ directional,
+ alleleDigest: representative.alleleDigest,
+ projected
+ }
+}
+
+// ── HEADLINE: what a one-line ClinVar surface displays ──
+
+/**
+ * A note qualifying a representative `call` — what, beyond the call itself, the headline should say.
+ * - `none` — a lone, unambiguous call; show it plainly.
+ * - `concordant` — the call represents ≥2 agreeing same-direction records; a quiet "representative of concordant
+ * records" aside.
+ * - `soft-vus` — a soft conflict where the co-occurring uncertain record is a plain VUS.
+ * - `soft-conflicting` — a soft conflict where ClinVar itself marks a related record *Conflicting*.
+ *
+ * The two `soft-*` values are split so the surface can word the two soft conflicts differently; `soft-conflicting`
+ * wins when both a VUS and a Conflicting record co-occur (ClinVar's explicit conflict verdict is the stronger flag).
+ */
+export type ClinvarCallNote = 'none' | 'concordant' | 'soft-vus' | 'soft-conflicting'
+
+/**
+ * What a one-headline ClinVar surface should display for a measurement — a discriminated union so the
+ * component switches on `kind` instead of re-deriving a precedence ladder in its template.
+ * - `conflicting` — hard-discordant winning set; no single call to show.
+ * - `call` — the representative usable call (assayed-level or an encoding), with a {@link ClinvarCallNote}
+ * qualifying it (concordant aside, or a soft-conflict flag). The lib owns that decision so the template renders it.
+ * - `presence` — no usable call, but a ClinVar record exists on the measurement (a `-` germline-less
+ * submission); name the state and link out.
+ * - `absent` — a nucleotide-level measurement whose own allele has no ClinVar record, while related alleles
+ * do: we decline to project (see reduceControlPlacement), so the surface says the record is absent and
+ * offers the related records as context rather than promoting one to a call.
+ * - `none` — nothing reaches the measurement.
+ */
+export type ClinvarHeadline =
+ | {kind: 'conflicting'; placement: HardDiscordantPlacement}
+ | {kind: 'call'; clinvar: ClinvarAnnotation; placement: UsableControlPlacement; note: ClinvarCallNote}
+ | {kind: 'presence'; record: MeasurementClinvarRecord}
+ | {kind: 'absent'}
+ | {kind: 'none'}
+
+/** Derive the {@link ClinvarCallNote} for a usable placement — the lib's single display decision for a call. */
+function resolveCallNote(placement: UsableControlPlacement): ClinvarCallNote {
+ if (placement.discordance === 'concordant') return 'concordant'
+ if (placement.discordance === 'soft') {
+ return placement.classifications.some((c) => isConflicting(c.significance)) ? 'soft-conflicting' : 'soft-vus'
+ }
+ return 'none'
+}
+
+/**
+ * Resolve what a one-headline ClinVar surface shows, over a resolved walk. Runs the level-gated fold, then
+ * falls back through a fixed precedence — see {@link ClinvarHeadline} for what each kind means — so every
+ * such surface (stat cell, variant screen) agrees:
+ * 1. a usable/hard placement → `call`/`conflicting`;
+ * 2. else the measured allele's own record, if any (a `-`, since a classification would have folded
+ * above) → `presence`;
+ * 3. else, if related alleles carry records → `absent` (never promoted to a call);
+ * 4. else → `none`.
+ */
+export function resolveClinvarHeadline(
+ records: MeasurementClinvarRecord[],
+ subject: SubjectDigest,
+ assayLevel?: SequenceLevel | null
+): ClinvarHeadline {
+ const placement = reduceControlPlacement(
+ records.map((r) => ({
+ significance: r.clinvar.clinicalSignificance,
+ reviewStatus: r.clinvar.clinicalReviewStatus,
+ alleleDigest: r.digest
+ })),
+ subject,
+ assayLevel
+ )
+ if (placement?.discordance === 'hard') return {kind: 'conflicting', placement}
+ if (placement) {
+ const representative = records.find((r) => r.digest === placement.alleleDigest)
+ if (representative) {
+ return {kind: 'call', clinvar: representative.clinvar, placement, note: resolveCallNote(placement)}
+ }
+ }
+ // The subject allele's own record (if any) is germline-less — a classification would have folded above.
+ // `onAssayed` was tagged against this same subject, so it covers its projection too.
+ const assayedRecord = records.find((r) => r.onAssayed)
+ if (assayedRecord) return {kind: 'presence', record: assayedRecord}
+ // No record on the measured allele. Any records that reach it are on *related* alleles — we won't promote
+ // one to a call or a germline-less state on this variant; say the record is absent and offer them as context.
+ return records.length > 0 ? {kind: 'absent'} : {kind: 'none'}
+}
diff --git a/src/lib/clinvar-control-series.test.ts b/src/lib/clinvar-control-series.test.ts
new file mode 100644
index 00000000..d794294f
--- /dev/null
+++ b/src/lib/clinvar-control-series.test.ts
@@ -0,0 +1,117 @@
+import {describe, expect, test} from 'vitest'
+
+import {reduceControlPlacement, type ControlLink} from '@/lib/clinvar-control-placement'
+import {resolveControlSeries, type ControlSeriesOptions} from '@/lib/clinvar-control-series'
+
+// Review statuses and their star ratings (mirrors CLINVAR_REVIEW_STATUS_STARS).
+const ONE_STAR = 'criteria provided, single submitter' // 1★
+const TWO_STAR = 'criteria provided, multiple submitters, no conflicts' // 2★
+
+const ASSAY = 'assayed-level-digest'
+const SIB = 'sibling-digest'
+const SIB2 = 'sibling-digest-2'
+
+const P = 'Pathogenic'
+const LP = 'Likely pathogenic'
+const PLP = 'Pathogenic/Likely pathogenic'
+const B = 'Benign'
+const LB = 'Likely benign'
+const BLB = 'Benign/Likely benign'
+const VUS = 'Uncertain significance'
+const CONFLICTING = 'Conflicting classifications of pathogenicity'
+
+function link(significance: string, alleleDigest?: string, reviewStatus: string = ONE_STAR): ControlLink {
+ return {significance, reviewStatus, alleleDigest, dbIdentifier: `${significance}@${alleleDigest ?? '?'}`}
+}
+
+describe('resolveControlSeries — single-membership histogram placement', () => {
+ // Everything selected (all directional + both uncertain calls) and no star floor, unless a test overrides it,
+ // so membership is decided by the placement/toggle, not filtered out by selection or stars.
+ const ALL_SELECTED = [P, LP, PLP, B, LB, BLB, VUS, CONFLICTING]
+ const opts = (softConflictsEnabled: boolean, over: Partial = {}): ControlSeriesOptions => ({
+ softConflictsEnabled,
+ selectedSignificances: ALL_SELECTED,
+ minStars: Number.NEGATIVE_INFINITY,
+ ...over
+ })
+ const place = (...links: ControlLink[]) => reduceControlPlacement(links, ASSAY)
+
+ test('no placement → null in either mode', () => {
+ expect(resolveControlSeries(null, opts(true))).toBeNull()
+ expect(resolveControlSeries(undefined, opts(false))).toBeNull()
+ })
+
+ test('hard discordance → null in either mode (never a valid home)', () => {
+ const hard = place(link(P, SIB), link(B, SIB2))
+ expect(resolveControlSeries(hard, opts(true))).toBeNull()
+ expect(resolveControlSeries(hard, opts(false))).toBeNull()
+ })
+
+ describe('clean/concordant directional → its directional series, in either mode', () => {
+ test.each([
+ ['single P', [link(P, ASSAY)], 'pathogenic'],
+ ['single LB', [link(LB, ASSAY)], 'benign'],
+ ['concordant {P, LP}', [link(P, SIB), link(LP, SIB2)], 'pathogenic'],
+ ['concordant {B, LB}', [link(B, SIB), link(LB, SIB2)], 'benign']
+ ])('%s → %s', (_label, links, expected) => {
+ const p = place(...links)
+ expect(resolveControlSeries(p, opts(true))).toBe(expected)
+ expect(resolveControlSeries(p, opts(false))).toBe(expected)
+ })
+ })
+
+ describe('soft conflict → directional lean only while the fold is on', () => {
+ test.each([
+ ['directional + VUS', [link(LP, SIB), link(VUS, SIB2)], 'pathogenic'],
+ ['directional + Conflicting', [link(B, SIB), link(CONFLICTING, SIB2)], 'benign']
+ ])('%s → %s when on, null when off', (_label, links, expected) => {
+ const p = place(...links)
+ expect(p!.discordance).toBe('soft')
+ expect(resolveControlSeries(p, opts(true))).toBe(expected)
+ expect(resolveControlSeries(p, opts(false))).toBeNull()
+ })
+ })
+
+ describe('pure uncertain → its uncertain series only while the fold is off (mutual exclusion)', () => {
+ test('single VUS → uncertain when off, null when on', () => {
+ const p = place(link(VUS, ASSAY))
+ expect(resolveControlSeries(p, opts(false))).toBe('uncertain')
+ expect(resolveControlSeries(p, opts(true))).toBeNull()
+ })
+
+ test('lone Conflicting → conflicting when off, null when on', () => {
+ const p = place(link(CONFLICTING, ASSAY))
+ expect(resolveControlSeries(p, opts(false))).toBe('conflicting')
+ expect(resolveControlSeries(p, opts(true))).toBeNull()
+ })
+
+ test('{VUS, Conflicting} → exactly one uncertain series (its representative), never both', () => {
+ const p = place(link(VUS, SIB, TWO_STAR), link(CONFLICTING, SIB2, ONE_STAR))
+ // Representative is the higher-star uncertain call (VUS @ 2★), so it lands in the VUS series alone.
+ expect(resolveControlSeries(p, opts(false))).toBe('uncertain')
+ expect(resolveControlSeries(p, opts(true))).toBeNull()
+ })
+ })
+
+ describe('the star gate is on the representative', () => {
+ test('representative below minStars → null', () => {
+ const p = place(link(P, ASSAY, ONE_STAR))
+ expect(resolveControlSeries(p, opts(true, {minStars: 2}))).toBeNull()
+ })
+
+ test('representative clearing minStars → placed', () => {
+ const p = place(link(P, ASSAY, TWO_STAR))
+ expect(resolveControlSeries(p, opts(true, {minStars: 2}))).toBe('pathogenic')
+ })
+ })
+
+ describe('selection gates on the representative (single-representative membership)', () => {
+ // Concordant {P@2★, LP@1★}: representative is the higher-star P. It is placed once, by P — not by both
+ // filters. So an "LP-only" selection excludes it even though LP is in the winning set.
+ test('representative P dropped when only LP is selected', () => {
+ const p = place(link(P, SIB, TWO_STAR), link(LP, SIB2, ONE_STAR))
+ expect(resolveControlSeries(p, opts(true, {selectedSignificances: [LP]}))).toBeNull()
+ expect(resolveControlSeries(p, opts(true, {selectedSignificances: [P]}))).toBe('pathogenic')
+ })
+ })
+})
diff --git a/src/lib/clinvar-control-series.ts b/src/lib/clinvar-control-series.ts
new file mode 100644
index 00000000..62ad422a
--- /dev/null
+++ b/src/lib/clinvar-control-series.ts
@@ -0,0 +1,88 @@
+/**
+ * @fileoverview
+ * How the score-set histogram bins a ClinVar {@link ClinvarControlPlacement} into a single filtered series.
+ *
+ * This is the histogram's clinical control visualization of placements ({@link module:clinvar-control-placement}):
+ * Given a ClinVar placement, this module answers which of the four clinical histogram series it belongs to, or null
+ * if it is excluded by the user's filters.
+ *
+ */
+
+import {
+ BENIGN_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS,
+ CLINVAR_REVIEW_STATUS_STARS,
+ CONFLICTING_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS,
+ DEFAULT_CLNREVSTAT_FIELD,
+ DEFAULT_CLNSIG_FIELD,
+ PATHOGENIC_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS,
+ UNCERTAIN_SIGNIFICANCE_CLASSIFICATIONS
+} from '@/lib/clinvar-controls'
+import type {ClinvarControlPlacement} from '@/lib/clinvar-control-placement'
+
+const isPathogenic = (significance: string) => PATHOGENIC_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS.includes(significance)
+const isBenign = (significance: string) => BENIGN_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS.includes(significance)
+const isDirectional = (significance: string) => isPathogenic(significance) || isBenign(significance)
+const isConflicting = (significance: string) => CONFLICTING_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS.includes(significance)
+const starsOf = (reviewStatus: string) => CLINVAR_REVIEW_STATUS_STARS[reviewStatus] ?? -1
+
+/** The clinical-control histogram series a variant can be placed in — exactly one, or none. */
+export type ClinvarControlSeriesKey = 'pathogenic' | 'benign' | 'uncertain' | 'conflicting'
+
+/** How the histogram should read a placement into a series: the soft-conflict fold, the selection, the star gate. */
+export interface ControlSeriesOptions {
+ softConflictsEnabled: boolean
+ /** The significances the user has selected to include; membership gates on the *representative's* own value. */
+ selectedSignificances: string[]
+ /** Minimum review-status stars the representative must clear. */
+ minStars: number
+}
+
+/** Map a directional representative to its aggregate series, gated on it being a selected significance. */
+function directionalSeries(representative: string, selected: string[]): ClinvarControlSeriesKey | null {
+ if (!selected.includes(representative)) return null
+ if (isPathogenic(representative)) return 'pathogenic'
+ return isBenign(representative) ? 'benign' : null
+}
+
+/** Map an uncertain representative (VUS or Conflicting) to its series, gated on it being selected. */
+function uncertainSeries(representative: string, selected: string[]): ClinvarControlSeriesKey | null {
+ if (!selected.includes(representative)) return null
+ if (UNCERTAIN_SIGNIFICANCE_CLASSIFICATIONS.includes(representative)) return 'uncertain'
+ return isConflicting(representative) ? 'conflicting' : null
+}
+
+/**
+ * The single clinical-control series a placement belongs to — or null (excluded). **Never two:** membership is
+ * fixed by the placement's representative call, so a variant is placed once, not once per granularity filter it
+ * happens to intersect (this guarantees we don't "double count" controls).
+ *
+ * - no placement / hard discordance → null (no representative; never a valid home);
+ * - representative below `minStars` → null (the star gate is on the *representative*, not "any winning call");
+ * - soft conflict (a directional lean beside an uncertain/Conflicting record) → its directional series, but only
+ * while `softConflictsEnabled`; off, it is excluded so the uncertain series stay genuinely uncertain;
+ * - a pure uncertain representative (VUS / lone Conflicting) → its uncertain series, but only while
+ * `softConflictsEnabled` is OFF (the soft-fold and uncertain-series are mutually exclusive modes);
+ * - a clean or concordant directional representative → its directional series.
+ *
+ * `selectedSignificances` gates on the representative's own significance, so e.g. a variant whose representative
+ * is `Pathogenic` drops out when only `Likely pathogenic` is selected.
+ */
+export function resolveControlSeries(
+ placement: ClinvarControlPlacement | null | undefined,
+ opts: ControlSeriesOptions
+): ClinvarControlSeriesKey | null {
+ if (!placement || placement.discordance === 'hard') return null
+ if (starsOf(placement[DEFAULT_CLNREVSTAT_FIELD]) < opts.minStars) return null
+
+ const representative = placement[DEFAULT_CLNSIG_FIELD]
+ // soft conflict: place by the directional lean, but only while the soft-fold is on.
+ if (placement.discordance === 'soft') {
+ return opts.softConflictsEnabled ? directionalSeries(representative, opts.selectedSignificances) : null
+ }
+ // none/concordant: place by the representative's own bucket.
+ if (isDirectional(representative)) {
+ return directionalSeries(representative, opts.selectedSignificances)
+ }
+ // A pure uncertain representative shows only in the uncertain-series mode.
+ return opts.softConflictsEnabled ? null : uncertainSeries(representative, opts.selectedSignificances)
+}
diff --git a/src/lib/clinvar-controls.test.ts b/src/lib/clinvar-controls.test.ts
new file mode 100644
index 00000000..def94385
--- /dev/null
+++ b/src/lib/clinvar-controls.test.ts
@@ -0,0 +1,248 @@
+import {describe, expect, test} from 'vitest'
+
+import {
+ clinicalSignificanceColor,
+ clinvarVersionKey,
+ enumerateUnderlyingClinvar,
+ isUncertainSignificance,
+ type MeasurementClinvarRecord,
+ resolveClinvarRecords,
+ selectClinvar
+} from '@/lib/clinvar-controls'
+import type {components} from '@/schema/openapi'
+
+type ClinvarAnnotation = components['schemas']['ClinvarAnnotation']
+
+const PATHOGENIC = 'var(--color-badge-pathogenic)'
+const BENIGN = 'var(--color-badge-benign)'
+
+// A ClinVar annotation with sensible defaults; only the fields a test cares about need overriding.
+function clinvar(overrides: Partial & {clinvarAlleleId: string}): ClinvarAnnotation {
+ return {
+ clinicalSignificance: 'Uncertain significance',
+ clinicalReviewStatus: 'criteria provided, single submitter',
+ clinvarVariationId: null,
+ dbVersion: '03_2024',
+ ...overrides
+ }
+}
+
+/** The ClinVar record ids in a resolved/enumerated list, in returned order. */
+const ids = (list: MeasurementClinvarRecord[]) =>
+ list.map((item) => item.clinvar.clinvarVariationId ?? item.clinvar.clinvarAlleleId)
+
+/** Enumerate the underlying popover records from a raw annotations map (the real walk → project path). */
+const enumerate = (
+ annotations: Parameters[0],
+ alleles: Parameters[1],
+ version?: string | null
+) => enumerateUnderlyingClinvar(resolveClinvarRecords(annotations, alleles, null, version))
+
+describe('clinicalSignificanceColor', () => {
+ test.each([
+ ['Pathogenic', PATHOGENIC],
+ ['Likely pathogenic', PATHOGENIC],
+ ['Pathogenic/Likely pathogenic', PATHOGENIC],
+ ['Benign', BENIGN],
+ ['Likely benign', BENIGN],
+ ['Benign/Likely benign', BENIGN]
+ ])('%s → directional color', (significance, color) => {
+ expect(clinicalSignificanceColor(significance)).toBe(color)
+ })
+
+ test.each([
+ 'Uncertain significance',
+ 'Conflicting classifications of pathogenicity',
+ 'Conflicting interpretations of pathogenicity',
+ '-',
+ '',
+ null,
+ undefined
+ ])('%s → undefined (caller keeps its default color)', (significance) => {
+ expect(clinicalSignificanceColor(significance)).toBeUndefined()
+ })
+
+ test('case-insensitive', () => {
+ expect(clinicalSignificanceColor('PATHOGENIC')).toBe(PATHOGENIC)
+ expect(clinicalSignificanceColor('likely benign')).toBe(BENIGN)
+ })
+})
+
+describe('resolveClinvarRecords — the single walk', () => {
+ test('nullish annotations → empty', () => {
+ expect(resolveClinvarRecords(null, null, null)).toEqual([])
+ expect(resolveClinvarRecords(undefined, undefined, undefined)).toEqual([])
+ })
+
+ test('keeps `-` records (tagged unclassified), skips alleles with no record', () => {
+ const annotations = {
+ a: {clinvar: [clinvar({clinvarAlleleId: 'a', clinicalSignificance: '-'})]},
+ b: {clinvar: [clinvar({clinvarAlleleId: 'b', clinicalSignificance: 'Pathogenic'})]},
+ c: {clinvar: null}
+ }
+ const records = resolveClinvarRecords(annotations, {}, null)
+ expect(ids(records)).toEqual(['a', 'b'])
+ expect(records.find((r) => r.digest === 'a')?.classified).toBe(false)
+ expect(records.find((r) => r.digest === 'b')?.classified).toBe(true)
+ })
+
+ test('tags onAssayed against the measured digest and pairs HGVS from the sidecar', () => {
+ const annotations = {
+ a: {clinvar: [clinvar({clinvarAlleleId: 'a', clinicalSignificance: 'Pathogenic'})]},
+ b: {clinvar: [clinvar({clinvarAlleleId: 'b', clinicalSignificance: 'Benign'})]}
+ }
+ const records = resolveClinvarRecords(annotations, {a: {hgvs: 'c.10A>G'}}, 'a')
+ expect(records.find((r) => r.digest === 'a')).toMatchObject({onAssayed: true, hgvs: 'c.10A>G'})
+ expect(records.find((r) => r.digest === 'b')).toMatchObject({onAssayed: false, hgvs: null})
+ })
+})
+
+describe('enumerateUnderlyingClinvar — the popover projection', () => {
+ test('nullish annotations → empty', () => {
+ expect(enumerate(null, null)).toEqual([])
+ expect(enumerate(undefined, undefined)).toEqual([])
+ })
+
+ test('keeps `-` records — only the fold drops them — sorted after classified calls', () => {
+ const annotations = {
+ a: {clinvar: [clinvar({clinvarAlleleId: 'a', clinicalSignificance: '-'})]},
+ b: {clinvar: [clinvar({clinvarAlleleId: 'b', clinicalSignificance: 'Pathogenic'})]},
+ c: {clinvar: null}
+ }
+ // b (directional) leads; a (`-`, non-directional) trails; c has no record.
+ expect(ids(enumerate(annotations, {}))).toEqual(['b', 'a'])
+ })
+
+ test('keeps a distinct encoding as context beside the measured allele`s own call', () => {
+ const annotations = {
+ assayed: {clinvar: [clinvar({clinvarAlleleId: 'assayed', clinicalSignificance: 'Pathogenic'})]},
+ sib: {clinvar: [clinvar({clinvarAlleleId: 'sib', clinicalSignificance: 'Uncertain significance'})]}
+ }
+ // The measured allele's own call is the headline; the genuinely distinct encoding is offered as context.
+ expect(ids(enumerateUnderlyingClinvar(resolveClinvarRecords(annotations, {}, 'assayed')))).toEqual(['sib'])
+ })
+
+ test('the same record under a non-assayed reference frame is not "underlying" the assayed call', () => {
+ const annotations = {
+ // One ClinVar record (variation V1), annotated on the measured protein allele `p` and again on its
+ // genomic frame `g`. `g` is not a distinct encoding — it's the same record you're already looking at.
+ p: {clinvar: [clinvar({clinvarAlleleId: 'x', clinvarVariationId: 'V1'})]},
+ g: {clinvar: [clinvar({clinvarAlleleId: 'x', clinvarVariationId: 'V1'})]}
+ }
+ expect(ids(enumerateUnderlyingClinvar(resolveClinvarRecords(annotations, {}, 'p')))).toEqual([])
+ })
+
+ test('an unclassified (`-`) assayed record does not win — the encodings still surface', () => {
+ const annotations = {
+ assayed: {clinvar: [clinvar({clinvarAlleleId: 'assayed', clinicalSignificance: '-'})]},
+ sib: {clinvar: [clinvar({clinvarAlleleId: 'sib', clinicalSignificance: 'Uncertain significance'})]}
+ }
+ // A `-` on the measured allele carries no call, so the fold projects from the encoding — which is underlying.
+ expect(ids(enumerateUnderlyingClinvar(resolveClinvarRecords(annotations, {}, 'assayed')))).toEqual(['sib'])
+ })
+
+ test('keeps a projected headline`s source encoding (measured allele carries no record)', () => {
+ const annotations = {
+ assayed: {clinvar: null},
+ sib: {clinvar: [clinvar({clinvarAlleleId: 'sib', clinicalSignificance: 'Pathogenic'})]}
+ }
+ // The encoding the protein-level headline was projected from is still an underlying record.
+ expect(ids(enumerateUnderlyingClinvar(resolveClinvarRecords(annotations, {}, 'assayed')))).toEqual(['sib'])
+ })
+
+ test('sorts directional calls ahead of VUS, then by descending star rating', () => {
+ const annotations = {
+ vus: {clinvar: [clinvar({clinvarAlleleId: 'vus', clinicalSignificance: 'Uncertain significance'})]},
+ lp1: {
+ clinvar: [
+ clinvar({
+ clinvarAlleleId: 'lp1',
+ clinicalSignificance: 'Likely pathogenic',
+ clinicalReviewStatus: 'criteria provided, single submitter'
+ })
+ ]
+ },
+ p3: {
+ clinvar: [
+ clinvar({
+ clinvarAlleleId: 'p3',
+ clinicalSignificance: 'Pathogenic',
+ clinicalReviewStatus: 'reviewed by expert panel'
+ })
+ ]
+ }
+ }
+ // p3 (directional, 3★) → lp1 (directional, 1★) → vus (non-directional, last).
+ expect(ids(enumerate(annotations, {}))).toEqual(['p3', 'lp1', 'vus'])
+ })
+
+ test('selects by version and dedupes by ClinVar record id (prefers a coding HGVS label)', () => {
+ const annotations = {
+ cDigest: {
+ clinvar: [
+ clinvar({clinvarAlleleId: 'x', clinvarVariationId: '123', clinicalSignificance: 'Benign', dbVersion: '03_2024'}),
+ clinvar({clinvarAlleleId: 'x', clinvarVariationId: '123', clinicalSignificance: 'Pathogenic', dbVersion: '06_2024'})
+ ]
+ },
+ gDigest: {
+ clinvar: [clinvar({clinvarAlleleId: 'x', clinvarVariationId: '123', clinicalSignificance: 'Benign', dbVersion: '03_2024'})]
+ }
+ }
+ const alleles = {cDigest: {hgvs: 'NM_1.2:c.10A>G'}, gDigest: {hgvs: 'NC_1.11:g.100A>G'}}
+ // Both digests select the 03_2024 record (same variation id) → one entry, coding HGVS wins as the label.
+ const result = enumerate(annotations, alleles, '03_2024')
+ expect(result).toHaveLength(1)
+ expect(result[0].hgvs).toBe('NM_1.2:c.10A>G')
+ expect(result[0].clinvar.clinicalSignificance).toBe('Benign')
+ // Pinning the newer release selects the pathogenic record from the allele that carries it.
+ const newer = enumerate(annotations, alleles, '06_2024')
+ expect(newer).toHaveLength(1)
+ expect(newer[0].clinvar.clinicalSignificance).toBe('Pathogenic')
+ })
+})
+
+describe('clinvarVersionKey — MM_YYYY ordered by year then month', () => {
+ test.each([
+ ['06_2024', '03_2024', true], // same year, later month is newer
+ ['01_2024', '12_2020', true], // the string-comparison trap: Jan 2024 beats Dec 2020
+ ['12_2020', '01_2024', false],
+ ['11_2023', '12_2020', true]
+ ])('%s newer than %s → %s', (a, b, aNewer) => {
+ expect(clinvarVersionKey(a) > clinvarVersionKey(b)).toBe(aNewer)
+ })
+
+ test('unrecognized versions sort to the bottom', () => {
+ expect(clinvarVersionKey('clinvar_2025')).toBe(-1)
+ expect(clinvarVersionKey('01_2000') > clinvarVersionKey('garbage')).toBe(true)
+ })
+})
+
+describe('selectClinvar — the release fallback', () => {
+ test('with no version pinned, returns the newest release (not the string-max)', () => {
+ // Old string comparison picked 12_2020 ('1' > '0'); the parsed key must pick 01_2024.
+ const annotations = [
+ clinvar({clinvarAlleleId: 'x', clinicalSignificance: 'Benign', dbVersion: '12_2020'}),
+ clinvar({clinvarAlleleId: 'x', clinicalSignificance: 'Pathogenic', dbVersion: '01_2024'})
+ ]
+ expect(selectClinvar(annotations)?.dbVersion).toBe('01_2024')
+ expect(selectClinvar(annotations)?.clinicalSignificance).toBe('Pathogenic')
+ })
+
+ test('with a version pinned, returns the exact match or null', () => {
+ const annotations = [clinvar({clinvarAlleleId: 'x', dbVersion: '03_2024'})]
+ expect(selectClinvar(annotations, '03_2024')?.dbVersion).toBe('03_2024')
+ expect(selectClinvar(annotations, '06_2024')).toBeNull()
+ })
+})
+
+describe('isUncertainSignificance', () => {
+ test.each([
+ ['Uncertain significance', true],
+ ['Conflicting classifications of pathogenicity', true],
+ ['Conflicting interpretations of pathogenicity', true],
+ ['Pathogenic', false],
+ ['Likely benign', false]
+ ])('%s → %s', (significance, expected) => {
+ expect(isUncertainSignificance(significance)).toBe(expected)
+ })
+})
diff --git a/src/lib/clinvar-controls.ts b/src/lib/clinvar-controls.ts
new file mode 100644
index 00000000..61df64bd
--- /dev/null
+++ b/src/lib/clinvar-controls.ts
@@ -0,0 +1,374 @@
+import type {KeySection} from '@/composables/use-key-drawer'
+import {type SubjectDigest, toSubjectDigestSet} from '@/lib/annotation-subject'
+import {hgvsLabelRank} from '@/lib/formats'
+import type {components} from '@/schema/openapi'
+
+export type ClinvarControlOption = components['schemas']['ClinicalControlOptions']
+export type ClinvarVariantLink = components['schemas']['ClinvarVariantLink']
+export type ClinvarControl = components['schemas']['ClinicalControlWithClinvarLinks']
+
+/** Key-drawer glossary for the ClinVar clinical-significance buckets this module classifies into. */
+export const CLINICAL_SIGNIFICANCE_KEY_SECTION: KeySection = {
+ id: 'clinical',
+ title: 'Clinical significance (ClinVar)',
+ gloss: 'Germline classifications, shown with their ClinVar review-star rating.',
+ terms: [
+ {label: 'Pathogenic / Likely pathogenic', definition: 'Classified as disease-causing, or likely to be.'},
+ {label: 'Benign / Likely benign', definition: 'Classified as not disease-causing, or likely not to be.'},
+ {label: 'VUS', definition: 'Variant of uncertain significance — not enough evidence to classify.'},
+ {label: 'Conflicting', definition: 'Submitters disagree on the classification.'}
+ ]
+}
+
+/** Key-drawer glossary for inferred ClinVar controls (calls carried over from a related allele). */
+export const INFERRED_CONTROL_KEY_SECTION: KeySection = {
+ id: 'inferred',
+ title: 'Inferred controls',
+ terms: [
+ {
+ label: 'Inferred call',
+ definition:
+ 'A ClinVar call inferred from a related variant with the same protein change — shown only when the assayed variant has no ClinVar record of its own.'
+ }
+ ]
+}
+
+type AlleleAnnotations = components['schemas']['AlleleAnnotations']
+type ClinvarAnnotation = components['schemas']['ClinvarAnnotation']
+
+/**
+ * ClinVar clinical significance classifications, excluding "Conflicting" — that one is version-dependent
+ * (ClinVar's terminology changed in 2025) and generated separately by
+ * {@link clinvarConflictingSignificanceClassificationForVersion}.
+ */
+export const CLINVAR_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS = [
+ {
+ name: 'Pathogenic',
+ description: 'Pathogenic variant',
+ shortDescription: 'Pathogenic'
+ },
+ {
+ name: 'Likely pathogenic',
+ description: 'Likely pathogenic variant',
+ shortDescription: 'LP'
+ },
+ {
+ name: 'Pathogenic/Likely pathogenic',
+ description: 'Pathogenic/Likely pathogenic variant (in different submissions)',
+ shortDescription: 'Pathogenic / Likely pathogenic'
+ },
+ {
+ name: 'Benign',
+ description: 'Benign variant',
+ shortDescription: 'Benign'
+ },
+ {
+ name: 'Likely benign',
+ description: 'Likely benign variant',
+ shortDescription: 'LB'
+ },
+ {
+ name: 'Benign/Likely benign',
+ description: 'Benign/Likely benign variant (in different submissions)',
+ shortDescription: 'Benign / Likely benign'
+ },
+ {
+ name: 'Uncertain significance',
+ description: 'Variant of uncertain significance',
+ shortDescription: 'VUS'
+ }
+]
+
+export const BENIGN_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS = ['Likely benign', 'Benign', 'Benign/Likely benign']
+
+export const PATHOGENIC_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS = [
+ 'Likely pathogenic',
+ 'Pathogenic',
+ 'Pathogenic/Likely pathogenic'
+]
+
+export const CONFLICTING_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS = [
+ 'Conflicting interpretations of pathogenicity',
+ 'Conflicting classifications of pathogenicity'
+]
+
+export const UNCERTAIN_SIGNIFICANCE_CLASSIFICATIONS = ['Uncertain significance']
+
+export const CLINVAR_REVIEW_STATUS_STARS: {[status: string]: number} = {
+ 'no assertion criteria provided': 0,
+ 'criteria provided, conflicting interpretations': 1,
+ 'criteria provided, conflicting classifications': 1,
+ 'criteria provided, single submitter': 1,
+ 'criteria provided, multiple submitters, no conflicts': 2,
+ 'reviewed by expert panel': 3,
+ 'practice guideline': 4
+}
+
+export const DEFAULT_CLNSIG_FIELD = 'clinicalSignificance'
+export const DEFAULT_CLNREVSTAT_FIELD = 'clinicalReviewStatus'
+
+export const DEFAULT_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS = [
+ 'Likely pathogenic',
+ 'Pathogenic',
+ 'Pathogenic/Likely pathogenic',
+ 'Likely benign',
+ 'Benign',
+ 'Benign/Likely benign'
+]
+export const DEFAULT_MIN_STAR_RATING = 1
+
+export const DEFAULT_CLINVAR_CONTROL_DB = 'ClinVar'
+
+/** Germline-less (`-`) ClinVar records carry no call; renderers show this instead of a bare dash. */
+export const NO_GERMLINE_CLASSIFICATION_LABEL = 'No germline classification'
+
+/** Turn a ClinVar `dbVersion` like `03_2024` into "March 2024"; pass through anything unrecognized. */
+export function formatClinvarVersion(dbVersion: string): string {
+ const match = dbVersion.match(/^(\d{2})_(\d{4})$/)
+ if (!match) return dbVersion
+ const [, month, year] = match
+ return new Date(Number(year), Number(month) - 1).toLocaleString('en-US', {month: 'long', year: 'numeric'})
+}
+
+/**
+ * A comparable sort key for a ClinVar `dbVersion` in `MM_YYYY` form, ordering by year then month — the
+ * frontend mirror of the API's `clinvar_version_sort_key`. `MM_YYYY` must NOT be compared as a raw string
+ * (month-first, so `"12_2020" > "01_2024"`); this parses it. Unrecognized versions sort to the bottom (`-1`).
+ */
+export function clinvarVersionKey(dbVersion: string): number {
+ const match = dbVersion.match(/^(\d{2})_(\d{4})$/)
+ if (!match) return -1
+ const [, month, year] = match
+ return Number(year) * 100 + Number(month)
+}
+
+/** Deep link to a ClinVar allele record, or null when the allele id is missing. */
+function clinvarAlleleUrl(alleleId: string | null | undefined): string | null {
+ if (!alleleId) return null
+ return `http://www.ncbi.nlm.nih.gov/clinvar/?term=${encodeURIComponent(alleleId)}[alleleid]`
+}
+
+/** Deep link to a ClinVar variation record, or null when the variation id is missing. */
+function clinvarVariationUrl(variationId: string | null | undefined): string | null {
+ if (!variationId) return null
+ return `https://www.ncbi.nlm.nih.gov/clinvar/variation/${encodeURIComponent(variationId)}/`
+}
+
+/**
+ * Deep link to a ClinVar record — prefers the variation page, falls back to the allele page. Structural:
+ * accepts any object carrying the id fields, whichever format it arrives in — a `ClinvarAnnotation`
+ * (`clinvarVariationId`/`clinvarAlleleId`) or a clinical control (`dbIdentifier`, an allele id).
+ */
+export function clinvarVariantUrl(record: {
+ clinvarVariationId?: string | null
+ clinvarAlleleId?: string | null
+ dbIdentifier?: string | null
+}): string | null {
+ return (
+ clinvarVariationUrl(record.clinvarVariationId) ??
+ clinvarAlleleUrl(record.clinvarAlleleId ?? record.dbIdentifier) ??
+ null
+ )
+}
+
+/**
+ * The badge color for a ClinVar clinical significance — pathogenic red, benign green, and `undefined` for
+ * everything else (VUS, conflicting, a `-`), so callers fall back to their own default text color.
+ * Substring match so the P/LP and B/LB aggregate labels all resolve to the directional color.
+ */
+export function clinicalSignificanceColor(significance: string | null | undefined): string | undefined {
+ const s = significance?.toLowerCase() ?? ''
+ if (s.includes('conflicting')) return undefined
+ if (s.includes('pathogenic')) return 'var(--color-badge-pathogenic)'
+ if (s.includes('benign')) return 'var(--color-badge-benign)'
+ return undefined
+}
+
+/**
+ * The ClinVar annotation for an allele at a given release, or null when it has none there. With no
+ * `version`, returns the most recent by `dbVersion` — the fallback used when a release isn't pinned.
+ */
+export function selectClinvar(
+ clinvar: ClinvarAnnotation[] | null | undefined,
+ version?: string | null
+): ClinvarAnnotation | null {
+ if (!clinvar?.length) return null
+ if (version) return clinvar.find((c) => c.dbVersion === version) ?? null
+ return clinvar.reduce((best, c) => (clinvarVersionKey(c.dbVersion) > clinvarVersionKey(best.dbVersion) ? c : best))
+}
+
+/** The most recent ClinVar annotation (by `dbVersion`), or null when there are none. */
+export function latestClinvar(annotations: AlleleAnnotations | null): ClinvarAnnotation | null {
+ return selectClinvar(annotations?.clinvar)
+}
+
+/**
+ * One ClinVar record reaching a measurement, resolved at a release. The single walk every ClinVar surface
+ * projects from: the fold reads `clinvar`/`digest`, the popover enumerates the `classified` subset, and the
+ * headline's `-` fallback reads `onAssayed` to prefer the measured allele's own record.
+ */
+export interface MeasurementClinvarRecord {
+ /** VRS digest of the allele this record annotates. */
+ digest: string
+ /** True when the record is on the measured allele itself (digest === assayLevelDigest), not an encoding. */
+ onAssayed: boolean
+ /** Reference-frame HGVS of the annotated allele, for labeling; null when the sidecar has none. */
+ hgvs: string | null
+ /** True for a real classification; false for a `-` germline-less (somatic/oncogenicity-only) placeholder. */
+ classified: boolean
+ clinvar: ClinvarAnnotation
+}
+
+/** Canonical identity of a ClinVar record: the variation id when present, else the allele id. Dedupes records
+ * shared across reference frames, keys them in a list, and excludes the one already shown in a headline. */
+export function clinvarRecordId(clinvar: {clinvarVariationId?: string | null; clinvarAlleleId: string}): string {
+ return clinvar.clinvarVariationId ?? clinvar.clinvarAlleleId
+}
+
+/**
+ * Resolve the ClinVar records reaching one measurement at `version` — the single walk over the annotations
+ * map that the fold, the underlying-record popover, and the `-` headline fallback all project from, so no
+ * surface re-walks it. One record per annotated allele digest (unclassified `-` records included, tagged);
+ * downstream projections filter/fold as they need. `subject` is the measured/page allele's digest(s);
+ * a record on any of them is the subject's own (`onAssayed`).
+ */
+export function resolveClinvarRecords(
+ annotations: Record | null | undefined,
+ alleles: Record | null | undefined,
+ subject: SubjectDigest,
+ version?: string | null
+): MeasurementClinvarRecord[] {
+ if (!annotations) return []
+ const subjectSet = toSubjectDigestSet(subject)
+ const records: MeasurementClinvarRecord[] = []
+ for (const [digest, ann] of Object.entries(annotations)) {
+ const clinvar = selectClinvar(ann.clinvar, version)
+ if (!clinvar) continue
+ records.push({
+ digest,
+ onAssayed: subjectSet.has(digest),
+ hgvs: alleles?.[digest]?.hgvs ?? null,
+ classified: isClassifiedSignificance(clinvar.clinicalSignificance),
+ clinvar
+ })
+ }
+ return records
+}
+
+/**
+ * The distinct *underlying* records for the popover, projected from a resolved walk — the records on the
+ * encodings the headline folded over. Excludes the measured allele's own record (`onAssayed`): that is the
+ * primary, already shown as the headline, not "underlying" — so a lone assayed record yields no popover, and
+ * a protein-level allele's projected headline still lists the encoding it was drawn from.
+ *
+ * These are the *related-allele* records offered as context — beside a direct call (transparency: records
+ * that did not drive it), beneath a projected call (the encodings it folded over), or under an `absent`
+ * nucleotide headline. Excludes the measured allele's own record (`onAssayed`) **and its cross-frame
+ * duplicates**: the same ClinVar record seen under another reference-frame digest is the very record already
+ * shown, not a distinct encoding, so it must not reappear here.
+ *
+ * Germline-less `-` submissions are kept (a record that exists is worth linking to; only the control fold
+ * drops `-`). Dedupes by ClinVar record id across the DNA/protein frames that share one record (preferring a
+ * coding HGVS for the label), and sorts directional calls (P/LP, B/LB) ahead of VUS and `-`, then by stars.
+ */
+export function enumerateUnderlyingClinvar(records: MeasurementClinvarRecord[]): MeasurementClinvarRecord[] {
+ // The measured allele's own record id(s): exclude these and any other frame carrying the same record.
+ const assayedIds = new Set(records.filter((r) => r.onAssayed).map((r) => clinvarRecordId(r.clinvar)))
+ const byRecord = new Map()
+ for (const rec of records) {
+ if (rec.onAssayed) continue
+ const key = clinvarRecordId(rec.clinvar)
+ if (assayedIds.has(key)) continue
+ const existing = byRecord.get(key)
+ if (!existing) byRecord.set(key, {...rec})
+ else if (hgvsLabelRank(rec.hgvs) > hgvsLabelRank(existing.hgvs)) existing.hgvs = rec.hgvs
+ }
+ const isDirectional = (significance: string) =>
+ PATHOGENIC_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS.includes(significance) ||
+ BENIGN_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS.includes(significance)
+ return [...byRecord.values()].sort((a, b) => {
+ const dirDelta =
+ Number(isDirectional(b.clinvar.clinicalSignificance)) - Number(isDirectional(a.clinvar.clinicalSignificance))
+ if (dirDelta !== 0) return dirDelta
+ return (
+ (CLINVAR_REVIEW_STATUS_STARS[b.clinvar.clinicalReviewStatus] ?? 0) -
+ (CLINVAR_REVIEW_STATUS_STARS[a.clinvar.clinicalReviewStatus] ?? 0)
+ )
+ })
+}
+
+/**
+ * The label to render for a ClinVar `clinicalSignificance` — the classification itself, or, for a `-`
+ * germline-less (somatic/oncogenicity-only) placeholder, {@link NO_GERMLINE_CLASSIFICATION_LABEL} rather
+ * than a bare dash that reads as "no record". The single wording every ClinVar renderer shares.
+ */
+export function formatClinicalSignificance(significance: string | null | undefined): string {
+ return isClassifiedSignificance(significance) ? significance! : NO_GERMLINE_CLASSIFICATION_LABEL
+}
+
+/**
+ * Whether a ClinVar `clinicalSignificance` is an actual classification. ClinVar's split germline/somatic
+ * model emits a literal `-` (occasionally empty) on an axis with no submission — e.g. a record carrying
+ * somatic/oncogenicity data but no germline classification. Such a value is not a usable classification:
+ * it must not become a clinvar control, render as a call, or (as a `-` on the *assayed* allele) block
+ * fall-through to an encoding. Filter significances through this before treating them as calls.
+ */
+export function isClassifiedSignificance(significance: string | null | undefined): boolean {
+ const s = significance?.trim()
+ return !!s && !/^-+$/.test(s)
+}
+
+/** Whether a significance is an uncertain call — a plain VUS or a ClinVar-*Conflicting* aggregate value. */
+export function isUncertainSignificance(significance: string): boolean {
+ return (
+ UNCERTAIN_SIGNIFICANCE_CLASSIFICATIONS.includes(significance) ||
+ CONFLICTING_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS.includes(significance)
+ )
+}
+
+/** {@link CLINVAR_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS} plus the version-appropriate "Conflicting" entry. */
+export function clinvarClinicalSignificanceClassifications(
+ version: string | null
+): typeof CLINVAR_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS {
+ return [
+ ...CLINVAR_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS,
+ clinvarConflictingSignificanceClassificationForVersion(version)
+ ]
+}
+
+/**
+ * The "Conflicting" classification, worded for the given ClinVar version — ClinVar renamed this from
+ * "interpretations" to "classifications" starting with the 2025 releases (`version`'s year after 2024).
+ * `null` (unknown version) is treated as post-rename.
+ */
+export function clinvarConflictingSignificanceClassificationForVersion(version: string | null): {
+ name: string
+ description: string
+ shortDescription: string
+} {
+ if (version === null || Number(version.split('_')[1]) > 2024) {
+ return {
+ name: 'Conflicting classifications of pathogenicity',
+ description: 'Variant with conflicting classifications of pathogenicity',
+ shortDescription: 'Conflicting'
+ }
+ } else {
+ return {
+ name: 'Conflicting interpretations of pathogenicity',
+ description: 'Variant with conflicting interpretations of pathogenicity',
+ shortDescription: 'Conflicting'
+ }
+ }
+}
+
+/**
+ * The "Conflicting classifications"/"Conflicting interpretations" series label — same version rule as
+ * {@link clinvarConflictingSignificanceClassificationForVersion}.
+ */
+export function conflictingClinicalSignificanceSeriesLabelForVersion(version: string | null): string {
+ if (version === null || Number(version.split('_')[1]) > 2024) {
+ return 'Conflicting classifications'
+ } else {
+ return 'Conflicting interpretations'
+ }
+}
diff --git a/src/lib/consequences.test.ts b/src/lib/consequences.test.ts
new file mode 100644
index 00000000..6a5fd44f
--- /dev/null
+++ b/src/lib/consequences.test.ts
@@ -0,0 +1,124 @@
+import {describe, expect, it} from 'vitest'
+
+import {
+ consequenceBucket,
+ humanReadableConsequence,
+ EFFECT_BUCKETS,
+ type EffectBucketName
+} from '@/lib/consequences'
+
+// The full VEP consequence set the API can emit.
+const ALL_CONSEQUENCES = [
+ 'transcript_ablation',
+ 'splice_acceptor_variant',
+ 'splice_donor_variant',
+ 'stop_gained',
+ 'frameshift_variant',
+ 'stop_lost',
+ 'start_lost',
+ 'transcript_amplification',
+ 'inframe_insertion',
+ 'inframe_deletion',
+ 'missense_variant',
+ 'disruptive_inframe_insertion',
+ 'disruptive_inframe_deletion',
+ 'protein_altering_variant',
+ 'splice_region_variant',
+ 'incomplete_terminal_codon_variant',
+ 'start_retained',
+ 'stop_retained',
+ 'synonymous_variant',
+ 'coding_sequence_variant',
+ 'mature_miRNA_variant',
+ '5_prime_UTR_premature_start_codon_gain_variant',
+ '5_prime_UTR_variant',
+ '3_prime_UTR_variant',
+ 'non_coding_transcript_exon_variant',
+ 'non_coding_exon_variant',
+ 'non_coding_transcript_variant',
+ 'nc_transcript_variant',
+ 'upstream_gene_variant',
+ 'downstream_gene_variant',
+ 'TFBS_ablation',
+ 'TFBS_amplification',
+ 'TF_binding_site_variant',
+ 'regulatory_region_ablation',
+ 'enhancer_ablation',
+ 'regulatory_region_amplification',
+ 'enhancer_amplification',
+ 'regulatory_region_variant',
+ 'feature_elongation',
+ 'regulatory_region',
+ 'TFBS',
+ 'feature_truncation',
+ 'exon_variant',
+ 'gene_variant',
+ 'variant_affecting_coding_sequence_conservation',
+ 'variant_affecting_genome_assembly_quality',
+ 'variant_of_unknown_significance',
+ 'sequence_variant',
+ 'rare_amino_acid_variant',
+ 'intron_variant',
+ 'intergenic_variant'
+]
+
+describe('consequenceBucket', () => {
+ it('maps headline terms to their bucket', () => {
+ expect(consequenceBucket('missense_variant')).toBe('Missense')
+ expect(consequenceBucket('synonymous_variant')).toBe('Synonymous')
+ expect(consequenceBucket('stop_gained')).toBe('Nonsense')
+ expect(consequenceBucket('start_lost')).toBe('Start/Stop Loss')
+ expect(consequenceBucket('stop_lost')).toBe('Start/Stop Loss')
+ expect(consequenceBucket('frameshift_variant')).toBe('Indel/Frameshift')
+ expect(consequenceBucket('inframe_deletion')).toBe('Indel/Frameshift')
+ expect(consequenceBucket('splice_acceptor_variant')).toBe('Splice')
+ })
+
+ it('maps an unrecognized but present term to Other', () => {
+ expect(consequenceBucket('intron_variant')).toBe('Other')
+ expect(consequenceBucket('some_future_so_term')).toBe('Other')
+ })
+
+ it('maps an absent consequence to No consequence', () => {
+ expect(consequenceBucket(null)).toBe('No consequence')
+ expect(consequenceBucket(undefined)).toBe('No consequence')
+ expect(consequenceBucket('')).toBe('No consequence')
+ expect(consequenceBucket('NA')).toBe('No consequence')
+ })
+
+ it('never maps a real consequence term to No consequence, and always to a defined bucket', () => {
+ const bucketNames = new Set(EFFECT_BUCKETS.map((b) => b.name))
+ for (const term of ALL_CONSEQUENCES) {
+ const bucket = consequenceBucket(term)
+ expect(bucket).not.toBe('No consequence')
+ expect(bucketNames.has(bucket)).toBe(true)
+ }
+ })
+
+ it('lists each SO term in at most one bucket', () => {
+ const seen = new Set()
+ for (const bucket of EFFECT_BUCKETS) {
+ for (const term of bucket.soTerms) {
+ expect(seen.has(term)).toBe(false)
+ seen.add(term)
+ }
+ }
+ })
+})
+
+describe('humanReadableConsequence', () => {
+ it('uses curated labels where the raw term reads poorly', () => {
+ expect(humanReadableConsequence('stop_gained')).toBe('Nonsense (stop gained)')
+ expect(humanReadableConsequence('start_lost')).toBe('Start loss')
+ })
+
+ it('de-underscores and capitalizes the long tail, preserving embedded casing', () => {
+ expect(humanReadableConsequence('splice_acceptor_variant')).toBe('Splice acceptor variant')
+ expect(humanReadableConsequence('5_prime_UTR_variant')).toBe('5 prime UTR variant')
+ })
+
+ it('returns null for an absent consequence', () => {
+ expect(humanReadableConsequence(null)).toBeNull()
+ expect(humanReadableConsequence('NA')).toBeNull()
+ })
+})
diff --git a/src/lib/consequences.ts b/src/lib/consequences.ts
new file mode 100644
index 00000000..98bbbeab
--- /dev/null
+++ b/src/lib/consequences.ts
@@ -0,0 +1,160 @@
+/**
+ * VEP consequence taxonomy for the score-set views.
+ *
+ * The API annotates each variant with a VEP functional consequence — a free-form Sequence Ontology
+ * (SO) term (`missense_variant`, `splice_acceptor_variant`, …). This module is the single source of
+ * truth for grouping that open-ended set into a small, fixed set of display buckets (the histogram
+ * effect series) and for rendering a term human-readably (tooltips). It operates on the consequence
+ * *string*, so it has no dependency on any variant shape and stays trivially testable.
+ */
+
+export type EffectBucketName =
+ | 'Missense'
+ | 'Synonymous'
+ | 'Nonsense'
+ | 'Start/Stop Loss'
+ | 'Indel/Frameshift'
+ | 'Splice'
+ | 'Other'
+ | 'No consequence'
+
+export interface EffectBucket {
+ name: EffectBucketName
+ color: string
+ description: string
+ /** VEP SO terms mapped into this bucket. Empty for the two catch-alls (`Other`, `No consequence`). */
+ soTerms: string[]
+}
+
+/**
+ * The display buckets, in series order. Every VEP consequence resolves to exactly one:
+ * - a term listed below → that bucket,
+ * - a term present but not listed → `Other` (so new API terms never break the UI — they still render
+ * their specific term in the tooltip),
+ * - no consequence at all (unmapped / unannotated) → `No consequence`.
+ */
+export const EFFECT_BUCKETS: EffectBucket[] = [
+ {
+ name: 'Missense',
+ color: '#ffcd3a',
+ description: 'Missense variants',
+ soTerms: ['missense_variant', 'rare_amino_acid_variant']
+ },
+ {
+ name: 'Synonymous',
+ color: '#6aa84f',
+ description: 'Synonymous variants',
+ soTerms: ['synonymous_variant', 'stop_retained', 'start_retained', 'incomplete_terminal_codon_variant']
+ },
+ {
+ name: 'Nonsense',
+ color: '#681a1a',
+ description: 'Nonsense (stop-gained) variants',
+ soTerms: ['stop_gained']
+ },
+ {
+ name: 'Start/Stop Loss',
+ color: '#cd3aff',
+ description: 'Start- and stop-loss variants',
+ soTerms: ['start_lost', 'stop_lost']
+ },
+ {
+ name: 'Indel/Frameshift',
+ color: '#ff8c00',
+ description: 'In-frame indels and frameshifts',
+ soTerms: [
+ 'frameshift_variant',
+ 'inframe_insertion',
+ 'inframe_deletion',
+ 'disruptive_inframe_insertion',
+ 'disruptive_inframe_deletion',
+ 'protein_altering_variant'
+ ]
+ },
+ {
+ name: 'Splice',
+ color: '#1f77b4',
+ description: 'Splice-site variants',
+ soTerms: ['splice_acceptor_variant', 'splice_donor_variant', 'splice_region_variant']
+ },
+ {
+ name: 'Other',
+ color: '#3affcd',
+ description: 'Other annotated consequences (non-coding, regulatory, …)',
+ soTerms: []
+ },
+ {
+ name: 'No consequence',
+ color: '#9e9e9e',
+ description: 'No VEP consequence (unmapped or unannotated)',
+ soTerms: []
+ }
+]
+
+const CONSEQUENCE_TO_BUCKET: Record = Object.fromEntries(
+ EFFECT_BUCKETS.flatMap((bucket) => bucket.soTerms.map((term) => [term, bucket.name]))
+)
+
+/**
+ * The display bucket for a VEP consequence term. `No consequence` when the term is absent/`NA`
+ * (unmapped or unannotated); `Other` when it is present but not one of the headline categories.
+ */
+export function consequenceBucket(consequence: string | null | undefined): EffectBucketName {
+ if (!consequence || consequence === 'NA') {
+ return 'No consequence'
+ }
+ return CONSEQUENCE_TO_BUCKET[consequence] ?? 'Other'
+}
+
+// A curated label where the raw SO term reads poorly as an effect (`stop_gained` is really "nonsense").
+// The long tail falls back to the de-underscored term with a capitalised first letter.
+const CONSEQUENCE_LABELS: Record = {
+ stop_gained: 'Nonsense (stop gained)',
+ stop_lost: 'Stop loss',
+ start_lost: 'Start loss'
+}
+
+/**
+ * A human-readable rendering of a VEP consequence term for tooltips. Known terms get a curated label;
+ * the long tail is the de-underscored term with its first letter capitalised, preserving embedded
+ * casing (`splice_acceptor_variant` → 'Splice acceptor variant', `5_prime_UTR_variant` → '5 prime UTR
+ * variant'). Returns `null` when there is no consequence.
+ */
+export function humanReadableConsequence(consequence: string | null | undefined): string | null {
+ if (!consequence || consequence === 'NA') {
+ return null
+ }
+ if (CONSEQUENCE_LABELS[consequence]) {
+ return CONSEQUENCE_LABELS[consequence]
+ }
+ const spaced = consequence.replace(/_/g, ' ').trim()
+ return spaced ? spaced.charAt(0).toUpperCase() + spaced.slice(1) : consequence
+}
+
+export interface EffectTypeFilterOption {
+ name: EffectBucketName
+ shortDescription: string
+ description: string
+}
+
+/** Filter checkbox options for the protein-effect and control panels — one per bucket, in series order. */
+export const EFFECT_TYPE_FILTER_OPTIONS: EffectTypeFilterOption[] = EFFECT_BUCKETS.map((bucket) => ({
+ name: bucket.name,
+ shortDescription: bucket.name,
+ description: bucket.description
+}))
+
+/**
+ * Buckets selected by default. Covers every annotated coding/other category so control variants of any
+ * kind still show without opt-in (as they did when `Other` was a catch-all). Excludes `Start/Stop Loss`
+ * (added conditionally by callers that hide it for synthetic targets) and `No consequence` (the
+ * unannotated pile is opt-in).
+ */
+export const DEFAULT_EFFECT_TYPE_FILTERS: EffectBucketName[] = [
+ 'Missense',
+ 'Synonymous',
+ 'Nonsense',
+ 'Indel/Frameshift',
+ 'Splice',
+ 'Other'
+]
diff --git a/src/lib/errors.ts b/src/lib/errors.ts
index db0f50e1..b11b7a32 100644
--- a/src/lib/errors.ts
+++ b/src/lib/errors.ts
@@ -7,7 +7,7 @@ import axios, {isAxiosError} from 'axios'
* back through the raw response body to the exception message. Always returns something renderable, so
* callers can drop it straight into a toast.
*
- * For the status code rather than the message, see `getErrorResponse` in `@/api/mavedb`.
+ * For the status code rather than the message, see {@link getErrorResponse} below.
*/
export function describeRequestError(error: unknown): string {
if (axios.isAxiosError(error) && error.response?.data) {
diff --git a/src/lib/formats.ts b/src/lib/formats.ts
index f8446199..39313bd1 100644
--- a/src/lib/formats.ts
+++ b/src/lib/formats.ts
@@ -2,6 +2,23 @@ import moment from 'moment'
import {Opts} from 'linkifyjs'
import linkifyHtml from 'linkify-html'
+/** Humanize a `snake_case` enum token → "Title Case", e.g. `missense_variant` → "Missense Variant". */
+export function formatConsequence(value: string | null | undefined): string {
+ if (!value) return '—'
+ return value.replace(/_/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase())
+}
+
+/**
+ * Rank an HGVS label for use as a human display label when several alleles collapse to one annotation:
+ * prefer a coding `c.` (reads more naturally), then genomic `g.`, then anything, then nothing.
+ */
+export function hgvsLabelRank(hgvs: string | null): number {
+ if (!hgvs) return 0
+ if (/(^|:)c\./.test(hgvs)) return 3
+ if (/(^|:)g\./.test(hgvs)) return 2
+ return 1
+}
+
export function formatDate(x: string) {
return moment(x).format('MMM DD, YYYY')
}
diff --git a/src/lib/functional-impact.ts b/src/lib/functional-impact.ts
new file mode 100644
index 00000000..4565d48a
--- /dev/null
+++ b/src/lib/functional-impact.ts
@@ -0,0 +1,54 @@
+/**
+ * The functional sublayer of the calibration layer: how an assay's verdict on a variant is named and
+ * shown. Its clinical counterpart (ClinVar significance, ACMG evidence) will live alongside as a separate
+ * module; the heavier editor/draft calibration model stays in `calibration-types.ts`.
+ */
+
+import type {KeySection} from '@/composables/use-key-drawer'
+
+export type FunctionalClassification = 'abnormal' | 'normal' | 'not_specified'
+
+/**
+ * Single source of truth for the functional-classification vocabulary: the full label, the compact
+ * label, the chip color class, the histogram range-fill color, and the Key-drawer gloss. The
+ * classification tag, the drawer's consequence section, and the calibration histogram all derive from
+ * this, so the vocabulary can never drift across surfaces. `class` styles the inline chip (the `--fn-*`
+ * palette); `rangeColor` fills the histogram score range (the `--cal-*` palette) — intentionally distinct.
+ * `not_specified` carries no gloss — it isn't surfaced in the drawer. Insertion order is display order.
+ */
+export const FUNCTIONAL_CLASSIFICATIONS: Record<
+ FunctionalClassification,
+ {label: string; shortLabel: string; class: string; rangeColor: string; definition?: string}
+> = {
+ abnormal: {
+ label: 'Functionally Abnormal',
+ shortLabel: 'Abnormal',
+ class: 'mave-classification-abnormal',
+ rangeColor: 'var(--color-cal-abnormal)',
+ definition: 'The assay scored the variant as altering function.'
+ },
+ normal: {
+ label: 'Functionally Normal',
+ shortLabel: 'Normal',
+ class: 'mave-classification-normal',
+ rangeColor: 'var(--color-cal-normal)',
+ definition: 'The assay scored the variant as retaining function.'
+ },
+ not_specified: {
+ label: 'Not Specified',
+ shortLabel: 'Not Specified',
+ class: 'mave-classification-not_specified',
+ rangeColor: 'var(--color-cal-unspecified)'
+ }
+}
+
+export const FUNCTIONAL_IMPACT_KEY_SECTION: KeySection = {
+ id: 'functional-impact',
+ title: 'Functional impact',
+ terms: [
+ {label: 'Functional impact', definition: "The assay's verdict on whether the variant alters function."},
+ ...Object.values(FUNCTIONAL_CLASSIFICATIONS)
+ .filter((c) => c.definition)
+ .map((c) => ({label: c.label, definition: c.definition!, class: c.class}))
+ ]
+}
diff --git a/src/lib/glossary-prose.ts b/src/lib/glossary-prose.ts
new file mode 100644
index 00000000..6ac2d999
--- /dev/null
+++ b/src/lib/glossary-prose.ts
@@ -0,0 +1,80 @@
+import type {KeySection} from '@/composables/use-key-drawer'
+
+// Single source for the "Your variant" concept — the page's own subject variant — as badged on the allele
+// ledger's page-role entry (MvAlleleLedger). Leads GLOSSARY_SECTIONS (glossary.ts): establishing the subject
+// first lets "Direct and indirect measurements" read naturally right after it.
+export const THIS_VARIANT_KEY_SECTION: KeySection = {
+ id: 'your-variant',
+ title: 'Your variant',
+ terms: [
+ {
+ label: 'Your variant',
+ definition: 'The variant this page is about.',
+ class: 'bg-subject/15 text-subject'
+ }
+ ]
+}
+
+export const CONSEQUENCE_KEY_SECTION: KeySection = {
+ id: 'consequence',
+ title: 'Molecular consequence',
+ terms: [
+ {
+ label: 'Molecular consequence',
+ definition: 'The predicted effect on the transcript or protein (e.g. missense), from VEP.'
+ }
+ ]
+}
+
+export const CALIBRATION_KEY_SECTION: KeySection = {
+ id: 'calibration',
+ title: 'Calibration',
+ terms: [
+ {
+ label: 'Calibration',
+ definition:
+ "A score set's score ranges, each tied to a functional impact and, where available, a strength of clinical evidence. This is how a functional score becomes a functional impact and an ACMG code."
+ },
+ {
+ label: 'Calibration control',
+ definition: 'A variant with an established clinical classification, used to derive the calibration.'
+ }
+ ]
+}
+
+export const NMD_KEY_SECTION: KeySection = {
+ id: 'nmd',
+ title: 'NMD',
+ terms: [
+ {
+ label: 'NMD',
+ definition:
+ 'Nonsense-mediated decay: a cellular process that destroys transcripts carrying premature stop codons. Assays built on a synthetic cDNA copy of the gene cannot detect variants that act this way.'
+ }
+ ]
+}
+
+export const AS_OF_KEY_SECTION: KeySection = {
+ id: 'as-of',
+ title: 'As of',
+ terms: [
+ {
+ label: 'As of MaveDB …',
+ definition: 'MaveDB reconstructs its molecular and annotation layer as of the chosen date; scores never change.'
+ },
+ {label: 'As of ClinVar …', definition: 'The ClinVar release a clinical call was drawn from.'}
+ ]
+}
+
+export const SUPERSEDED_KEY_SECTION: KeySection = {
+ id: 'superseded',
+ title: 'Superseded',
+ terms: [
+ {
+ label: 'Superseded',
+ definition:
+ 'This measurement is from an older version of its score set and might have outdated scores or classifications. Note that the score set which supersedes the older version may not include your variant.',
+ class: 'bg-superseded-light text-superseded'
+ }
+ ]
+}
diff --git a/src/lib/gnomad.test.ts b/src/lib/gnomad.test.ts
index c5eb4ee7..42992a97 100644
--- a/src/lib/gnomad.test.ts
+++ b/src/lib/gnomad.test.ts
@@ -1,16 +1,138 @@
import {describe, expect, it, test} from 'vitest'
+
import {
CHROMOSOME_REFSEQ_IDS,
+ collectGnomadFrequencies,
+ formatFrequency,
gnomadIdToHgvs,
gnomadIdToHgvsCandidates,
+ gnomadVariantUrl,
otherAssembly,
parseGnomadId,
- formatFrequency,
- gnomadFromVariantRow,
- gnomadVariantUrl,
- type GnomadFrequency
+ type UnderlyingGnomad
} from './gnomad'
-import type {RawVariant} from '@/lib/variants'
+
+type GnomadAnnotation = UnderlyingGnomad['gnomad']
+
+// A gnomAD annotation with sensible defaults; only the fields a test cares about need overriding.
+function gnomad(overrides: Partial & {dbIdentifier: string}): GnomadAnnotation {
+ return {
+ alleleFrequency: 0.001,
+ alleleCount: 10,
+ alleleNumber: 10000,
+ faf95Max: null,
+ dbVersion: '4',
+ ...overrides
+ }
+}
+
+/** The gnomAD variant ids in a collected list, in returned order. */
+const ids = (list: UnderlyingGnomad[]) => list.map((item) => item.gnomad.dbIdentifier)
+
+describe('collectGnomadFrequencies — enumeration of encoding-variant frequencies', () => {
+ test('nullish annotations → empty', () => {
+ expect(collectGnomadFrequencies(null, null)).toEqual([])
+ expect(collectGnomadFrequencies(undefined, undefined)).toEqual([])
+ })
+
+ test('no allele carries gnomAD → empty', () => {
+ const annotations = {'protein-digest': {}, 'other-digest': {gnomad: null}}
+ expect(collectGnomadFrequencies(annotations, {})).toEqual([])
+ })
+
+ test('collects one measurement per annotated allele, pairing the HGVS from the alleles sidecar', () => {
+ const annotations = {
+ 'digest-a': {gnomad: gnomad({dbIdentifier: '1-100-A-G', alleleFrequency: 0.002})},
+ 'digest-b': {gnomad: gnomad({dbIdentifier: '1-200-C-T', alleleFrequency: 0.001})}
+ }
+ const alleles = {'digest-a': {hgvs: 'c.10A>G'}, 'digest-b': {hgvs: 'c.20C>T'}}
+ const result = collectGnomadFrequencies(annotations, alleles)
+ expect(result).toHaveLength(2)
+ expect(result.find((r) => r.gnomad.dbIdentifier === '1-100-A-G')?.hgvs).toBe('c.10A>G')
+ expect(result.find((r) => r.gnomad.dbIdentifier === '1-200-C-T')?.hgvs).toBe('c.20C>T')
+ })
+
+ test('sorts by descending allele frequency (max first — drives the headline)', () => {
+ const annotations = {
+ low: {gnomad: gnomad({dbIdentifier: 'low', alleleFrequency: 0.0001})},
+ high: {gnomad: gnomad({dbIdentifier: 'high', alleleFrequency: 0.05})},
+ mid: {gnomad: gnomad({dbIdentifier: 'mid', alleleFrequency: 0.01})}
+ }
+ expect(ids(collectGnomadFrequencies(annotations, {}))).toEqual(['high', 'mid', 'low'])
+ })
+
+ test('missing HGVS is tolerated → null label', () => {
+ const annotations = {'digest-a': {gnomad: gnomad({dbIdentifier: '1-100-A-G'})}}
+ expect(collectGnomadFrequencies(annotations, {})[0]?.hgvs).toBeNull()
+ expect(collectGnomadFrequencies(annotations, {'digest-a': {hgvs: null}})[0]?.hgvs).toBeNull()
+ })
+
+ describe('deduplication by gnomAD variant id — the c/g members of one genomic variant share it', () => {
+ test('two digests, same dbIdentifier → one entry', () => {
+ const annotations = {
+ 'c-digest': {gnomad: gnomad({dbIdentifier: '1-100-A-G'})},
+ 'g-digest': {gnomad: gnomad({dbIdentifier: '1-100-A-G'})}
+ }
+ const alleles = {'c-digest': {hgvs: 'c.10A>G'}, 'g-digest': {hgvs: 'g.100A>G'}}
+ const result = collectGnomadFrequencies(annotations, alleles)
+ expect(result).toHaveLength(1)
+ })
+
+ test('coding HGVS is preferred as the label regardless of iteration order', () => {
+ // g-member seen first, c-member second.
+ const gFirst = {
+ 'g-digest': {gnomad: gnomad({dbIdentifier: '1-100-A-G'})},
+ 'c-digest': {gnomad: gnomad({dbIdentifier: '1-100-A-G'})}
+ }
+ // c-member seen first, g-member second.
+ const cFirst = {
+ 'c-digest': {gnomad: gnomad({dbIdentifier: '1-100-A-G'})},
+ 'g-digest': {gnomad: gnomad({dbIdentifier: '1-100-A-G'})}
+ }
+ const alleles = {'c-digest': {hgvs: 'NM_1.2:c.10A>G'}, 'g-digest': {hgvs: 'NC_1.11:g.100A>G'}}
+ expect(collectGnomadFrequencies(gFirst, alleles)[0]?.hgvs).toBe('NM_1.2:c.10A>G')
+ expect(collectGnomadFrequencies(cFirst, alleles)[0]?.hgvs).toBe('NM_1.2:c.10A>G')
+ })
+
+ test('genomic HGVS is preferred over a non-c/g label; a present label beats a missing one', () => {
+ const annotations = {
+ 'n-digest': {gnomad: gnomad({dbIdentifier: 'X'})},
+ 'g-digest': {gnomad: gnomad({dbIdentifier: 'X'})}
+ }
+ expect(collectGnomadFrequencies(annotations, {'n-digest': {hgvs: 'n.5A>G'}, 'g-digest': {hgvs: 'g.100A>G'}})[0]?.hgvs).toBe('g.100A>G')
+ expect(collectGnomadFrequencies(annotations, {'g-digest': {hgvs: 'g.100A>G'}})[0]?.hgvs).toBe('g.100A>G')
+ })
+ })
+
+ describe('subject exclusion — the subject`s own frequency is the headline, not a "related" one', () => {
+ test('excludes the subject digest', () => {
+ const annotations = {
+ subject: {gnomad: gnomad({dbIdentifier: 'S'})},
+ sib: {gnomad: gnomad({dbIdentifier: 'B'})}
+ }
+ expect(ids(collectGnomadFrequencies(annotations, {}, ['subject']))).toEqual(['B'])
+ })
+
+ test('excludes the subject`s projection (same gnomAD id on a non-subject digest)', () => {
+ // The subject (coding) has no gnomAD of its own; its genomic projection carries the record. Anchoring on both
+ // subject digests drops the projection so the subject`s own frequency is never listed as related.
+ const annotations = {
+ 'subject-c': {gnomad: null},
+ 'subject-g': {gnomad: gnomad({dbIdentifier: 'S'})},
+ sib: {gnomad: gnomad({dbIdentifier: 'B'})}
+ }
+ expect(ids(collectGnomadFrequencies(annotations, {}, ['subject-c', 'subject-g']))).toEqual(['B'])
+ })
+
+ test('no subject given → collects everything (backward compatible)', () => {
+ const annotations = {
+ subject: {gnomad: gnomad({dbIdentifier: 'S'})},
+ sib: {gnomad: gnomad({dbIdentifier: 'B', alleleFrequency: 0.002})}
+ }
+ expect(ids(collectGnomadFrequencies(annotations, {}))).toEqual(['B', 'S'])
+ })
+ })
+})
/** The GRCh38 translation of a gnomAD ID, which is the one tried first. */
function grch38Hgvs(gnomadId: string): string | undefined {
@@ -155,82 +277,6 @@ describe('CHROMOSOME_REFSEQ_IDS', () => {
})
})
-/** A variant data row whose gnomad namespace is fully populated; overrides replace individual cells. */
-function row(overrides: Partial> = {}): RawVariant {
- return {
- accession: 'urn:mavedb:00000001-a-1#1',
- scores: {score: 0.5},
- gnomad: {
- gnomad_af: 1.86e-6,
- gnomad_ac: 3,
- gnomad_an: 1613510,
- gnomad_faf95_max: 6.8e-7,
- gnomad_faf95_max_ancestry: 'nfe',
- gnomad_id: '10-87961093-A-G',
- gnomad_version: 'v4.1',
- ...overrides
- }
- }
-}
-
-const frequency: GnomadFrequency = {
- alleleFrequency: 1.86e-6,
- alleleCount: 3,
- alleleNumber: 1613510,
- faf95Max: 6.8e-7,
- faf95MaxAncestry: 'nfe',
- dbIdentifier: '10-87961093-A-G',
- dbVersion: 'v4.1'
-}
-
-describe('gnomadFromVariantRow', () => {
- test('reads a populated namespace into the display shape', () => {
- expect(gnomadFromVariantRow(row())).toEqual(frequency)
- })
-
- test('nullish row or absent namespace → null', () => {
- expect(gnomadFromVariantRow(null)).toBeNull()
- expect(gnomadFromVariantRow(undefined)).toBeNull()
- expect(gnomadFromVariantRow({accession: 'x', scores: {score: 0.5}})).toBeNull()
- })
-
- test("a variant with no gnomAD record reports 'NA' across the namespace → null", () => {
- const unannotated = row({
- gnomad_af: 'NA',
- gnomad_ac: 'NA',
- gnomad_an: 'NA',
- gnomad_faf95_max: 'NA',
- gnomad_faf95_max_ancestry: 'NA',
- gnomad_id: 'NA',
- gnomad_version: 'NA'
- })
- expect(gnomadFromVariantRow(unannotated)).toBeNull()
- })
-
- test.each(['gnomad_af', 'gnomad_ac', 'gnomad_an', 'gnomad_id'] as const)(
- 'a missing %s makes the record unusable → null',
- (field) => {
- expect(gnomadFromVariantRow(row({[field]: 'NA'}))).toBeNull()
- }
- )
-
- test('FAF95 is optional — absent leaves the rest intact', () => {
- const result = gnomadFromVariantRow(row({gnomad_faf95_max: 'NA', gnomad_faf95_max_ancestry: 'NA'}))
- expect(result).toMatchObject({alleleFrequency: 1.86e-6, faf95Max: null, faf95MaxAncestry: null})
- })
-
- test('a zero allele frequency is a real value, not a missing one', () => {
- expect(gnomadFromVariantRow(row({gnomad_af: 0, gnomad_ac: 0}))).toMatchObject({
- alleleFrequency: 0,
- alleleCount: 0
- })
- })
-
- test('an absent version degrades gracefully rather than dropping the record', () => {
- expect(gnomadFromVariantRow(row({gnomad_version: 'NA'}))?.dbVersion).toBe('unknown')
- })
-})
-
describe('formatFrequency', () => {
test('nullish → em dash', () => {
expect(formatFrequency(null)).toBe('—')
diff --git a/src/lib/gnomad.ts b/src/lib/gnomad.ts
index e3283600..2cb17085 100644
--- a/src/lib/gnomad.ts
+++ b/src/lib/gnomad.ts
@@ -2,35 +2,100 @@
* @fileoverview
* gnomAD population frequency annotations and related utilities.
*
- * gnomAD is a population-scale variant frequency database. MaveDB links each mapped variant to the
- * single gnomAD record sharing its ClinGen allele ID, so a variant's frequency is a direct assertion
- * about that variant — there is no projection or pooling to reason about.
+ * gnomAD is a population-scale variant frequency database. This module covers three concerns:
*
- * Frequencies reach the client as the `gnomad` namespace of the score-set variant data CSV, where
- * every field arrives as a number or the string `'NA'`. {@link gnomadFromVariantRow} is the seam that
- * turns one of those rows into the shape the display components consume.
+ * - The population-frequency glossary and the enumeration of the distinct frequencies across a variant
+ * record's alleles ({@link collectGnomadFrequencies}), used by the variant page.
+ * - Translation of gnomAD variant IDs (e.g. 1-11796321-G-A) into genomic HGVS, so an ID can be resolved
+ * against the ClinGen Allele Registry.
+ *
+ * A single gnomAD record is linked to a mapped variant by shared ClinGen allele ID, so each individual
+ * record is a direct assertion about that allele. A variant *record* may still span several alleles (the
+ * c/g members of one genomic change, and its projections), which is why the enumeration above exists.
+ *
+ * A gnomAD release is a property of a record, never of a page or a download: one variant record may sit
+ * at a different release from the next, so every display of a version is per record.
*/
-import {gnomadIdRegex} from './mavemd'
+
+import type {KeySection} from '@/composables/use-key-drawer'
+import {hgvsLabelRank} from '@/lib/formats'
import type {components} from '@/schema/openapi'
-import type {RawVariant} from '@/lib/variants'
+import {gnomadIdRegex} from './mavemd'
+
+type GnomadAnnotation = components['schemas']['GnomadAnnotation']
+
+/** Key-drawer glossary for the gnomAD population-frequency terms this module surfaces. */
+export const POPULATION_KEY_SECTION: KeySection = {
+ id: 'population',
+ title: 'Population frequency (gnomAD)',
+ gloss: 'How often the allele is seen in reference populations — high frequency argues against pathogenicity.',
+ terms: [
+ {
+ label: 'Allele frequency (AF)',
+ definition:
+ "The fraction of gnomAD's sampled reference-population chromosomes that carry this allele (allele count ÷ allele number)."
+ },
+ {
+ label: 'AC / AN',
+ definition:
+ 'Allele count and allele number: the observed carriers and the total chromosomes sampled. The two inputs behind the frequency above.'
+ },
+ {
+ label: 'FAF95',
+ definition:
+ "Filtering allele frequency at 95% confidence: a sampling-adjusted, conservative estimate of the population frequency. When it exceeds a disease's maximum credible allele frequency, the variant is too common to be pathogenic (ACMG BA1/BS1)."
+ }
+ ]
+}
+
+/** One underlying gnomAD measurement, tagged with the reference-frame HGVS of the allele it annotates. */
+export interface UnderlyingGnomad {
+ hgvs: string | null
+ gnomad: GnomadAnnotation
+}
/**
- * One gnomAD frequency record, as consumed by the display components.
+ * Collect the distinct gnomAD measurements across a variant record's alleles — the *related* frequencies
+ * shown as context.
*
- * Picked from the generated schema rather than restated, so renaming or retyping a field on the API's
- * model breaks compilation here.
+ * A protein change is encoded by several genomic variants, each with its own gnomAD frequency. Enumerate
+ * this set of distinct frequencies, deduplicating by gnomAD variant id and preferring a coding HGVS for
+ * the label. Sort by descending allele frequency.
*
- * Caveat: the CSV columns come from the API's namespace specs, a different code path from the view
- * model. Both project the same `GnomADVariant` ORM columns, so this tracks names and types but is not
- * a guarantee that the two stay column-for-column aligned.
+ * `subjectDigests` names the subject allele (the measured/page allele, including its projection); its own
+ * frequency is the headline, so it — and any other frame carrying the same gnomAD record — is excluded here,
+ * mirroring the ClinVar underlying-record enumeration.
*/
-export type GnomadFrequency = Pick<
- components['schemas']['GnomADVariantWithMappedVariants'],
- 'alleleFrequency' | 'alleleCount' | 'alleleNumber' | 'faf95Max' | 'faf95MaxAncestry' | 'dbIdentifier' | 'dbVersion'
->
+export function collectGnomadFrequencies(
+ annotations: Record | null | undefined,
+ alleles: Record | null | undefined,
+ subjectDigests?: Iterable
+): UnderlyingGnomad[] {
+ if (!annotations) return []
+ const subjectSet = new Set(subjectDigests ?? [])
+ // The subject's own gnomAD id(s): exclude these so the subject's frequency (or its projection's) never
+ // reappears as a "related" one.
+ const subjectIds = new Set()
+ for (const digest of subjectSet) {
+ const id = annotations[digest]?.gnomad?.dbIdentifier
+ if (id) subjectIds.add(id)
+ }
+ const byVariant = new Map()
+ for (const [digest, ann] of Object.entries(annotations)) {
+ const gnomad = ann.gnomad
+ if (!gnomad) continue
+ if (subjectSet.has(digest) || subjectIds.has(gnomad.dbIdentifier)) continue
-/** A CSV cell from the `gnomad` namespace: a number, the `'NA'` sentinel, or absent. */
-type GnomadCell = number | string | null | undefined
+ const hgvs = alleles?.[digest]?.hgvs ?? null
+ const existing = byVariant.get(gnomad.dbIdentifier)
+ if (!existing) {
+ byVariant.set(gnomad.dbIdentifier, {hgvs, gnomad})
+ } else if (hgvsLabelRank(hgvs) > hgvsLabelRank(existing.hgvs)) {
+ existing.hgvs = hgvs
+ }
+ }
+ return [...byVariant.values()].sort((a, b) => b.gnomad.alleleFrequency - a.gnomad.alleleFrequency)
+}
/**
* Translation of gnomAD variant IDs (e.g. 1-11796321-G-A) into genomic HGVS.
@@ -201,47 +266,6 @@ export function gnomadIdToHgvs(gnomadId: string, assembly: GenomeAssembly): stri
return gnomadIdToHgvsCandidates(gnomadId).find((candidate) => candidate.assembly === assembly)?.hgvs ?? null
}
-function numberOrNull(value: GnomadCell): number | null {
- return typeof value === 'number' && Number.isFinite(value) ? value : null
-}
-
-function stringOrNull(value: GnomadCell): string | null {
- if (typeof value === 'number') return String(value)
- return value && value.toUpperCase() !== 'NA' ? value : null
-}
-
-/**
- * Read a variant's gnomAD frequency out of its score-set data row.
- *
- * Returns null unless the row carries the fields the display depends on — the frequency itself, the
- * AC/AN behind it, and the gnomAD variant id used to link out. Variants with no gnomAD record report
- * `'NA'` across the namespace and yield null here.
- *
- * Requires the `gnomad` namespace to have been requested; see `variantPageVariantDataUrl`.
- */
-export function gnomadFromVariantRow(variant: RawVariant | null | undefined): GnomadFrequency | null {
- const gnomad = variant?.gnomad
- if (!gnomad) return null
-
- const alleleFrequency = numberOrNull(gnomad.gnomad_af)
- const alleleCount = numberOrNull(gnomad.gnomad_ac)
- const alleleNumber = numberOrNull(gnomad.gnomad_an)
- const dbIdentifier = stringOrNull(gnomad.gnomad_id)
- if (alleleFrequency == null || alleleCount == null || alleleNumber == null || dbIdentifier == null) {
- return null
- }
-
- return {
- alleleFrequency,
- alleleCount,
- alleleNumber,
- faf95Max: numberOrNull(gnomad.gnomad_faf95_max),
- faf95MaxAncestry: stringOrNull(gnomad.gnomad_faf95_max_ancestry),
- dbIdentifier,
- dbVersion: stringOrNull(gnomad.gnomad_version) ?? 'unknown'
- }
-}
-
/** Deep link to a gnomAD variant page, choosing the dataset that matches the record's version. */
export function gnomadVariantUrl(gnomad: {dbIdentifier: string; dbVersion: string}): string {
// Versions are stored with a leading "v" (e.g. "v4.1"), so strip non-digits before reading the major.
diff --git a/src/lib/heatmap.ts b/src/lib/heatmap.ts
index 5d6addc0..33c26262 100644
--- a/src/lib/heatmap.ts
+++ b/src/lib/heatmap.ts
@@ -717,12 +717,34 @@ export default function makeHeatmap(): Heatmap {
}
}
+ // Position the hover tooltip near the pointer, flipping left/up when it would spill past the viewport
+ // edge. Without this a tall tooltip on a thin (DNA) heatmap runs off the bottom of the screen.
+ const positionHoverTooltip = (event: MouseEvent) => {
+ if (!hoverTooltip) {
+ return
+ }
+ const node = hoverTooltip.node() as HTMLElement | null
+ const [pageX, pageY] = d3.pointer(event, document.body)
+ const width = node?.offsetWidth ?? 0
+ const height = node?.offsetHeight ?? 0
+ const margin = 8
+ const viewportRight = window.scrollX + document.documentElement.clientWidth
+ const viewportBottom = window.scrollY + document.documentElement.clientHeight
+
+ let left = pageX + 30
+ if (left + width > viewportRight - margin) {
+ left = pageX - 30 - width
+ }
+ let top = pageY
+ if (top + height > viewportBottom - margin) {
+ top = Math.max(window.scrollY + margin, pageY - height)
+ }
+ hoverTooltip.style('left', left + 'px').style('top', top + 'px')
+ }
+
const mousemove = (event: MouseEvent, d: HeatmapDatum) => {
if (!selectionStartDatum && hoverTooltip) {
- // Move tooltip to be 30px to the right of the pointer.
- hoverTooltip
- .style('left', d3.pointer(event, document.body)[0] + 30 + 'px')
- .style('top', d3.pointer(event, document.body)[1] + 'px')
+ positionHoverTooltip(event)
}
}
@@ -751,9 +773,7 @@ export default function makeHeatmap(): Heatmap {
if (hoverTooltip && target instanceof Element) {
showTickLabelTooltip(hoverTooltip, rowNumber)
- hoverTooltip
- .style('left', d3.pointer(event, document.body)[0] + 30 + 'px')
- .style('top', d3.pointer(event, document.body)[1] + 'px')
+ positionHoverTooltip(event)
}
}
diff --git a/src/lib/histogram.ts b/src/lib/histogram.ts
index a6300930..a05db33c 100644
--- a/src/lib/histogram.ts
+++ b/src/lib/histogram.ts
@@ -39,7 +39,9 @@ export interface HistogramMargins {
}
export interface HistogramSerieOptions {
- title?: string
+ // A plain title renders as one legend line. An array wraps it across multiple lines instead of
+ // widening the legend indefinitely — e.g. a base label plus a caveat that doesn't fit alongside it.
+ title?: string | string[]
color: string // TODO Make this optional by providing default colors.
}
@@ -908,24 +910,31 @@ export default function makeHistogram(): Histogram {
const legendX = 32
const legendY = 12
const legendItemHeight = 22
+ const legendWrappedLineHeight = 15 // extra vertical space per title line beyond the first
const legendFontSize = '13px'
const legendCircleWidth = 7
const legendSpacing = 5
const legend = svg.select('g.histogram-legend')
+ const legendItemsShown = chartHasContent && series.length > 1
+ const legendSeries = legendItemsShown ? series : []
+ const legendTitleLines = (d: HistogramSerie): string[] =>
+ Array.isArray(d.options.title) ? d.options.title : [d.options.title || '']
+ // Cumulative Y offset per item — an item's own title may wrap across more than one line, which
+ // pushes every item after it further down than a flat `index * legendItemHeight` would.
+ const legendItemOffsets: number[] = []
+ let legendCumulativeHeight = 0
+ for (const d of legendSeries) {
+ legendItemOffsets.push(legendCumulativeHeight)
+ legendCumulativeHeight += legendItemHeight + (legendTitleLines(d).length - 1) * legendWrappedLineHeight
+ }
const legendItem = legend
.selectAll('g.histogram-legend-item')
- .data(chartHasContent && series.length > 1 ? series : [])
+ .data(legendSeries)
.join(
(enter) => {
const g = enter.append('g').attr('class', 'histogram-legend-item')
g.append('circle').attr('r', legendCircleWidth).attr('cx', legendX)
- //.attr('cy', (d, i) => legendY + i * legendItemHeight)
- //.style('fill', (d) => d.options.color)
- g.append('text')
- .attr('x', legendX + legendCircleWidth + legendSpacing)
- .attr('y', (_d: HistogramSerie, i) => legendY + i * legendItemHeight + legendSpacing)
- .style('font-size', legendFontSize)
- //.text((d, i) => d.options.title || `Series ${i + 1}`)
+ g.append('text').attr('x', legendX + legendCircleWidth + legendSpacing).style('font-size', legendFontSize)
return g
},
(update) => update,
@@ -934,13 +943,23 @@ export default function makeHistogram(): Histogram {
legendItem
.select('circle')
// @ts-ignore
- .attr('cy', (_d: HistogramSerie, i) => legendY + i * legendItemHeight)
+ .attr('cy', (_d: HistogramSerie, i) => legendY + legendItemOffsets[i])
// @ts-ignore
.style('fill', (d: HistogramSerie) => d.options.color)
legendItem
.select('text')
// @ts-ignore
- .text((d: HistogramSerie, i) => d.options.title || `Series ${i + 1}`)
+ .attr('y', (_d: HistogramSerie, i) => legendY + legendItemOffsets[i] + legendSpacing)
+ .each(function (d: unknown, i: number) {
+ const lines = legendTitleLines(d as HistogramSerie)
+ d3.select(this)
+ .selectAll('tspan')
+ .data(lines.length ? lines : [`Series ${i + 1}`])
+ .join('tspan')
+ .attr('x', legendX + legendCircleWidth + legendSpacing)
+ .attr('dy', (_line: string, lineIndex: number) => (lineIndex === 0 ? 0 : legendWrappedLineHeight))
+ .text((line: string) => line)
+ })
// The client may have specified a line of text to display below the legend.
legend
@@ -950,7 +969,7 @@ export default function makeHistogram(): Histogram {
.attr('class', 'histogram-legend-note')
.attr('font-size', legendFontSize)
.attr('x', legendX - legendCircleWidth)
- .attr('y', legendY + (series.length == 1 ? 0 : series.length) * legendItemHeight + legendSpacing - 1)
+ .attr('y', legendY + (legendItemsShown ? legendCumulativeHeight : 0) + legendSpacing - 1)
.text((d) => d)
// Add a background for the legend, for visibility.
diff --git a/src/lib/mave-hgvs.test.ts b/src/lib/mave-hgvs.test.ts
new file mode 100644
index 00000000..f5e58d9f
--- /dev/null
+++ b/src/lib/mave-hgvs.test.ts
@@ -0,0 +1,25 @@
+import {describe, expect, it} from 'vitest'
+
+import {isNucleotideHgvs} from '@/lib/mave-hgvs'
+
+describe('isNucleotideHgvs', () => {
+ it('recognizes nucleotide-level prefixes, with or without an accession', () => {
+ expect(isNucleotideHgvs('NM_003345.4:c.324T>G')).toBe(true)
+ expect(isNucleotideHgvs('c.6C>T')).toBe(true)
+ expect(isNucleotideHgvs('g.123A>G')).toBe(true)
+ expect(isNucleotideHgvs('n.76A>C')).toBe(true)
+ expect(isNucleotideHgvs('m.8993T>G')).toBe(true)
+ })
+
+ it('rejects protein-level expressions', () => {
+ expect(isNucleotideHgvs('NP_000528.2:p.Leu6Gly')).toBe(false)
+ expect(isNucleotideHgvs('p.(Leu6Gly)')).toBe(false)
+ })
+
+ it('rejects empty / untyped input', () => {
+ expect(isNucleotideHgvs(null)).toBe(false)
+ expect(isNucleotideHgvs(undefined)).toBe(false)
+ expect(isNucleotideHgvs('')).toBe(false)
+ expect(isNucleotideHgvs('NA')).toBe(false)
+ })
+})
diff --git a/src/lib/mave-hgvs.ts b/src/lib/mave-hgvs.ts
index 8fb4c766..21122547 100644
--- a/src/lib/mave-hgvs.ts
+++ b/src/lib/mave-hgvs.ts
@@ -116,6 +116,24 @@ export function variantNotNullOrNA(variant: string | null | undefined): boolean
return variant ? variant.toLowerCase() !== 'na' : false
}
+/**
+ * Extract the HGVS "type" prefix — the single letter before the dot in an HGVS expression
+ * (e.g. `c` in `NM_003345.4:c.324T>G`, `p` in `NP_000528.2:p.Leu6Gly`). The prefix may sit
+ * at the start of the string or after an accession/colon. Returns the lowercased letter, or
+ * null if the string is empty or not recognizably typed.
+ */
+function hgvsTypePrefix(hgvs: string | null | undefined): string | null {
+ if (!hgvs) return null
+ const match = hgvs.match(/(?:^|:)\s*([cgmnrp])\./i)
+ return match ? match[1].toLowerCase() : null
+}
+
+/** Whether an HGVS expression is nucleotide-level (c./g./n./m./r.) rather than protein (p.). */
+export function isNucleotideHgvs(hgvs: string | null | undefined): boolean {
+ const prefix = hgvsTypePrefix(hgvs)
+ return prefix != null && prefix !== 'p'
+}
+
/**
* Return the preferred variant label for a given variant. Protein variation is preferred
* to nucleotide variation, which is preferred to splice variation.
diff --git a/src/lib/mavemd.test.ts b/src/lib/mavemd.test.ts
index a49fe4df..ac3acb53 100644
--- a/src/lib/mavemd.test.ts
+++ b/src/lib/mavemd.test.ts
@@ -1,6 +1,90 @@
import {describe, expect, it} from 'vitest'
-import {detectSearchType, geneSymbolSearchTarget, gnomadIdRegex} from './mavemd'
+import {
+ createAlleleResult,
+ detectSearchType,
+ geneSymbolSearchTarget,
+ gnomadIdRegex,
+ mergeAlleleSpellings,
+ type AlleleResult
+} from '@/lib/mavemd'
+
+function result(overrides: Partial): AlleleResult {
+ return {
+ clingenAlleleUrl: undefined,
+ clingenAlleleId: undefined,
+ canonicalAlleleName: undefined,
+ maneStatus: null,
+ genomicAlleles: [],
+ grch38Hgvs: null,
+ grch37Hgvs: null,
+ transcriptAlleles: [],
+ maneCoordinates: [],
+ variantsStatus: 'NotLoaded',
+ variants: {direct: [], proteinConsequence: [], nucleotideEncoding: []},
+ ...overrides
+ }
+}
+
+describe('createAlleleResult', () => {
+ it('titles a complete record from its communityStandardTitle', () => {
+ const allele = createAlleleResult(
+ {'@id': 'http://reg.genome.network/allele/CA123', communityStandardTitle: ['NM_x:c.818G>A']},
+ null
+ )
+ expect(allele.canonicalAlleleName).toBe('NM_x:c.818G>A')
+ })
+
+ it('falls back to the protein hgvs for a lean amino-acid record with no title', () => {
+ const allele = createAlleleResult(
+ {
+ '@id': 'http://reg.genome.network/allele/PA2579942745',
+ aminoAcidAlleles: [
+ {hgvs: ['NP_001484.1:p.Pro368Ser'], matchingRegisteredTranscripts: [{'@id': 'CA415209784'}]}
+ ]
+ },
+ null
+ )
+ expect(allele.canonicalAlleleName).toBe('NP_001484.1:p.Pro368Ser')
+ })
+
+ it('falls back to the ClinGen ID when a record has neither a title nor a protein hgvs', () => {
+ const allele = createAlleleResult(
+ {'@id': 'http://reg.genome.network/allele/PA2830778226', aminoAcidAlleles: [{}]},
+ null
+ )
+ expect(allele.canonicalAlleleName).toBe('PA2830778226')
+ })
+})
+
+describe('mergeAlleleSpellings', () => {
+ it('folds a transcript spelling onto the target and keeps distinct MANE coordinates', () => {
+ const target = result({
+ clingenAlleleId: 'PA9',
+ maneCoordinates: [{sequenceType: 'protein', database: 'RefSeq', hgvs: 'NP_x:p.Arg273His'}]
+ })
+ mergeAlleleSpellings(
+ target,
+ result({maneCoordinates: [{sequenceType: 'nucleotide', database: 'RefSeq', hgvs: 'NM_x:c.818G>A'}]})
+ )
+ expect(target.clingenAlleleId).toBe('PA9') // anchor unchanged
+ expect(target.maneCoordinates.map((c) => c.hgvs)).toEqual(['NP_x:p.Arg273His', 'NM_x:c.818G>A'])
+ })
+
+ it('deduplicates a coordinate shared across transcripts', () => {
+ const coord = {sequenceType: 'nucleotide', database: 'RefSeq', hgvs: 'NM_x:c.818G>A'}
+ const target = result({maneCoordinates: [coord]})
+ mergeAlleleSpellings(target, result({maneCoordinates: [{...coord}]}))
+ expect(target.maneCoordinates).toHaveLength(1)
+ })
+
+ it('fills a missing genome-build HGVS from the source without overwriting a present one', () => {
+ const target = result({grch38Hgvs: 'NC_x:g.100A>T', grch37Hgvs: null})
+ mergeAlleleSpellings(target, result({grch38Hgvs: 'other', grch37Hgvs: 'NC_y:g.200A>T'}))
+ expect(target.grch38Hgvs).toBe('NC_x:g.100A>T') // kept
+ expect(target.grch37Hgvs).toBe('NC_y:g.200A>T') // filled
+ })
+})
describe('detectSearchType', () => {
it('recognizes each supported identifier', () => {
diff --git a/src/lib/mavemd.ts b/src/lib/mavemd.ts
index dc9c7e66..8a934f21 100644
--- a/src/lib/mavemd.ts
+++ b/src/lib/mavemd.ts
@@ -2,12 +2,12 @@ import type {ClinGenAllele, ClinGenGenomicAllele, ClinGenTranscriptAllele} from
import type {components} from '@/schema/openapi'
import {hgvsSearchStringRegex} from './mave-hgvs'
-type VariantMeasurement = components['schemas']['VariantEffectMeasurementWithShortScoreSet']
+type AlleleMeasurement = components['schemas']['AlleleMeasurement']
/**
* Regular expression for valid CA or PA ids that can be used in ClinGen searches.
*/
-export const clinGenAlleleIdRegex = /^(CA|PA)[0-9]+$/im
+export const clinGenAlleleIdRegex = /^(CA|PA)[0-9]+$/i
/**
* Regular expression for GA4GH VRS identifiers: ga4gh:.<32-char base64url digest>.
@@ -16,23 +16,23 @@ export const vrsDigestRegex = /^ga4gh:[^.]+\.[0-9A-Za-z_-]{32}$/
/**
* Extracts the score set URN from a MaveDB variant URN.
- * Variant URNs follow the format urn:mavedb:XXXXXXXX-X-N-SUFFIX.
- * The score set URN is the first three hyphenated segments.
+ * Variant URNs follow the format urn:mavedb:XXXXXXXX-X-N#SUFFIX.
+ * The score set URN is the portion before the '#' variant separator.
*/
export function scoreSetUrnFromVariantUrn(variantUrn: string): string | null {
- const match = variantUrn.match(/^(urn:mavedb:[^-]+-[^-]+-[^-]+)(?:-.+)?$/)
+ const match = variantUrn.match(/^(urn:mavedb:[^-]+-[^-]+-[^-]+)#.+$/)
return match?.[1] ?? null
}
/**
* Regular expression for valid ClinVar Variation IDs that can be used in ClinGen searches.
*/
-export const clinVarVariationIdRegex = /^[0-9]+$/m
+export const clinVarVariationIdRegex = /^[0-9]+$/
/**
* Regular expression for valid Reference SNP cluster IDs that can be used in ClinGen searches.
*/
-export const rsIdRegex = /^rs[0-9]+$/im
+export const rsIdRegex = /^rs[0-9]+$/i
/**
* Regular expression for valid gnomAD variant IDs that can be used in ClinGen searches.
@@ -114,13 +114,36 @@ export interface AlleleResult {
transcriptAlleles: ClinGenTranscriptAllele[]
maneCoordinates: ManeCoordinate[]
variantsStatus: string
+ // Measurements of this allele's equivalence class, bucketed by each one's relationship to the searched
+ // change (mirrors the API `AlleleMeasurement.relationship`).
variants: {
- nucleotide: VariantMeasurement[]
- protein: VariantMeasurement[]
- associatedNucleotide: VariantMeasurement[]
+ direct: AlleleMeasurement[]
+ proteinConsequence: AlleleMeasurement[]
+ nucleotideEncoding: AlleleMeasurement[]
}
/** MaveDB variant URN — present when a VRS digest search resolves to a variant without a ClinGen Allele ID. */
- variantUrn?: string | null
+ variantUrn?: string
+}
+
+/**
+ * Fold one allele's spellings (transcript / genomic / MANE coordinates) into another. Used to collapse a
+ * protein change's several registered transcript alleles onto ONE search result — the change is a single
+ * finding, and its transcript spellings are representations to list, not separate hits. MANE coordinates
+ * are deduplicated so shared entries aren't repeated.
+ */
+export function mergeAlleleSpellings(target: AlleleResult, source: AlleleResult): void {
+ const seen = new Set(target.maneCoordinates.map((c) => `${c.sequenceType}|${c.database}|${c.hgvs}`))
+ for (const coord of source.maneCoordinates) {
+ const key = `${coord.sequenceType}|${coord.database}|${coord.hgvs}`
+ if (!seen.has(key)) {
+ seen.add(key)
+ target.maneCoordinates.push(coord)
+ }
+ }
+ target.transcriptAlleles.push(...source.transcriptAlleles)
+ target.genomicAlleles.push(...source.genomicAlleles)
+ target.grch38Hgvs ??= source.grch38Hgvs
+ target.grch37Hgvs ??= source.grch37Hgvs
}
/** Extract the trailing path segment from a URL (e.g. ClinGen allele ID from its URL). */
@@ -132,18 +155,23 @@ export function extractIdFromUrl(url: string | undefined): string | undefined {
/** Transform a raw ClinGen allele API response into an AlleleResult for display. */
export function createAlleleResult(data: ClinGenAllele, maneStatus: string | null): AlleleResult {
+ const clingenAlleleId = extractIdFromUrl(data['@id'])
const allele: AlleleResult = {
clingenAlleleUrl: data['@id'],
- clingenAlleleId: extractIdFromUrl(data['@id']),
- canonicalAlleleName: data.communityStandardTitle?.[0],
+ clingenAlleleId,
+ // Complete records carry a communityStandardTitle; lean amino-acid records (no title, genomic, or
+ // transcript alleles) only carry the protein hgvs. Fall back to that, then to the ClinGen ID, so the
+ // card always has a heading instead of rendering undefined.
+ canonicalAlleleName:
+ data.communityStandardTitle?.[0] ?? data.aminoAcidAlleles?.[0]?.hgvs?.[0] ?? clingenAlleleId,
maneStatus,
- genomicAlleles: data.genomicAlleles || [],
+ genomicAlleles: data.genomicAlleles ?? [],
grch38Hgvs: null,
grch37Hgvs: null,
- transcriptAlleles: data.transcriptAlleles || [],
+ transcriptAlleles: data.transcriptAlleles ?? [],
maneCoordinates: [],
variantsStatus: 'NotLoaded',
- variants: {nucleotide: [], protein: [], associatedNucleotide: []}
+ variants: {direct: [], proteinConsequence: [], nucleotideEncoding: []}
}
for (const genomicAllele of allele.genomicAlleles) {
@@ -160,16 +188,16 @@ export function createAlleleResult(data: ClinGenAllele, maneStatus: string | nul
for (const sequenceType of ['nucleotide', 'protein'] as const) {
const records = mane[sequenceType]
if (records) {
- for (const database in records) {
+ for (const [database, record] of Object.entries(records)) {
allele.maneCoordinates.push({
sequenceType,
database,
- hgvs: records[database].hgvs
+ hgvs: record.hgvs
})
}
}
}
- // Assuming all MANE transcripts have the same MANE status, we can set it from the first one we encounter.
+ // All MANE statuses should be identical, use the first.
break
}
}
diff --git a/src/lib/measurement-types.test.ts b/src/lib/measurement-types.test.ts
new file mode 100644
index 00000000..eb0f8a99
--- /dev/null
+++ b/src/lib/measurement-types.test.ts
@@ -0,0 +1,71 @@
+import {describe, expect, it} from 'vitest'
+
+import {assayLevelBucket, dominantAssayLevel, RELATIONSHIPS, RELATIONSHIP_KEY_SECTION} from '@/lib/measurement-types'
+
+describe('assayLevelBucket', () => {
+ it('returns "amino acid" for protein', () => {
+ expect(assayLevelBucket('protein')).toBe('amino acid')
+ })
+
+ it('returns "nucleotide" for cdna', () => {
+ expect(assayLevelBucket('cdna')).toBe('nucleotide')
+ })
+
+ it('returns "nucleotide" for genomic', () => {
+ expect(assayLevelBucket('genomic')).toBe('nucleotide')
+ })
+
+ it('returns "nucleotide" for null', () => {
+ expect(assayLevelBucket(null)).toBe('nucleotide')
+ })
+
+ it('returns "nucleotide" for undefined', () => {
+ expect(assayLevelBucket(undefined)).toBe('nucleotide')
+ })
+})
+
+describe('dominantAssayLevel', () => {
+ it('returns the most common non-null level', () => {
+ expect(dominantAssayLevel(['cdna', 'protein', 'cdna'])).toBe('cdna')
+ })
+
+ it('returns the most common level when all levels are the same', () => {
+ expect(dominantAssayLevel(['protein', 'protein', 'protein'])).toBe('protein')
+ })
+
+ it('returns the first level when there is a tie', () => {
+ expect(dominantAssayLevel(['cdna', 'protein', 'cdna', 'protein'])).toBe('cdna')
+ })
+
+ it('returns null when no levels are provided', () => {
+ expect(dominantAssayLevel([])).toBeNull()
+ })
+
+ it('returns null when all levels are null', () => {
+ expect(dominantAssayLevel([null, null])).toBeNull()
+ })
+
+ it('returns null when all levels are undefined', () => {
+ expect(dominantAssayLevel([undefined, undefined])).toBeNull()
+ })
+
+ it('ignores null and undefined levels', () => {
+ expect(dominantAssayLevel(['cdna', null, 'protein', undefined, 'cdna'])).toBe('cdna')
+ })
+})
+
+describe('RELATIONSHIPS', () => {
+ it('flags a direct measurement as Direct', () => {
+ expect(RELATIONSHIPS.direct.label).toBe('Direct')
+ })
+
+ it('flags both related-variant relationships with the same Indirect badge', () => {
+ expect(RELATIONSHIPS.protein_consequence).toEqual(RELATIONSHIPS.nucleotide_encoding)
+ expect(RELATIONSHIPS.protein_consequence.label).toBe('Indirect')
+ })
+
+ it('defines exactly the labels its badges can show in the Key drawer', () => {
+ const badgeLabels = new Set(Object.values(RELATIONSHIPS).map((r) => r.label))
+ expect(new Set(RELATIONSHIP_KEY_SECTION.terms.map((t) => t.label))).toEqual(badgeLabels)
+ })
+})
diff --git a/src/lib/measurement-types.ts b/src/lib/measurement-types.ts
index 2d41bf07..dca3ec7d 100644
--- a/src/lib/measurement-types.ts
+++ b/src/lib/measurement-types.ts
@@ -1,13 +1,93 @@
-export type MeasurementType = 'nucleotide' | 'protein' | 'associatedNucleotide'
+import type {components} from '@/schema/openapi'
+import type {KeySection} from '@/composables/use-key-drawer'
+import type {SequenceLevel} from '@/composables/use-variant-coordinates'
-export const MEASUREMENT_TYPE_LABELS: Record = {
- nucleotide: {full: 'Nucleotide level', short: 'Nucleotide'},
- protein: {full: 'Protein level', short: 'Protein'},
- associatedNucleotide: {full: 'Synonymous nucleotide', short: 'Synonymous'}
+export type MeasurementRelationship = components['schemas']['MeasurementRelationship']
+
+// The interface collapses the three sequence levels ('genomic' | 'cdna' | 'protein') to two assay-level
+// buckets: the nucleotide levels (genomic + coding) share one; protein is 'amino acid'. The finer
+// SequenceLevel stays available internally for logic — it just isn't surfaced as its own badge.
+export type LevelBucket = 'nucleotide' | 'amino acid'
+
+export function assayLevelBucket(level: string | null | undefined): LevelBucket {
+ return level === 'protein' ? 'amino acid' : 'nucleotide'
+}
+
+/**
+ * Single source of truth for the measurement-to-query relationship badge (the RT asymmetry). The badge only
+ * flags whether the measurement assayed the page's own variant: `direct`, or `indirect` for both
+ * related-variant relationships. The detail (which related variant, and its HGVS) is stated once, in the
+ * Functional evidence notice, so the vocabulary here stays two words.
+ */
+export const RELATIONSHIPS: Record = {
+ direct: {label: 'Direct', class: 'bg-subject/15 text-subject'},
+ protein_consequence: {label: 'Indirect', class: 'bg-convergent-light text-convergent'},
+ nucleotide_encoding: {label: 'Indirect', class: 'bg-convergent-light text-convergent'}
+}
+
+export const RELATIONSHIP_KEY_SECTION: KeySection = {
+ id: 'relationship',
+ title: 'Direct and indirect measurements',
+ gloss: 'Whether a measurement assayed your variant itself.',
+ terms: [
+ {
+ label: RELATIONSHIPS.direct.label,
+ definition: 'The measurement assayed your variant itself.',
+ class: RELATIONSHIPS.direct.class
+ },
+ {
+ label: RELATIONSHIPS.protein_consequence.label,
+ definition:
+ 'The measurement assayed a related variant instead: the protein change your variant produces, or a nucleotide variant that encodes the same protein change. Its score stands in for your variant rather than measuring it directly.',
+ class: RELATIONSHIPS.protein_consequence.class
+ }
+ ]
+}
+
+/**
+ * Single source of truth for how an assay-level bucket appears in the UI — its label, color classes, and
+ * Key-drawer gloss. Every level badge, pill, and the Key drawer's assay-level section derives from this,
+ * so the vocabulary can never drift across surfaces.
+ */
+export const LEVEL_BUCKETS: Record = {
+ nucleotide: {
+ label: 'Nucleotide',
+ class: 'bg-nucleotide-light text-nucleotide',
+ definition: 'Assayed as a nucleotide change.'
+ },
+ 'amino acid': {
+ label: 'Amino acid',
+ class: 'bg-amino-acid-light text-amino-acid',
+ definition: 'Assayed as an amino-acid change.'
+ }
+}
+
+export const ASSAY_LEVEL_KEY_SECTION: KeySection = {
+ id: 'assay-level',
+ title: 'Assay level',
+ gloss: 'The level at which a result measured the change.',
+ terms: Object.values(LEVEL_BUCKETS).map((b) => ({label: b.label, definition: b.definition, class: b.class}))
+}
+
+/** The display label, color classes, and gloss for a raw SequenceLevel, collapsed to its bucket. */
+export function assayLevelDisplay(level: string | null | undefined): (typeof LEVEL_BUCKETS)[LevelBucket] {
+ return LEVEL_BUCKETS[assayLevelBucket(level)]
}
-export const MEASUREMENT_TYPE_CLASSES: Record = {
- nucleotide: 'bg-nucleotide-light text-nucleotide',
- protein: 'bg-protein-light text-protein',
- associatedNucleotide: 'bg-synonymous-nucleotide-light text-synonymous-nucleotide'
+// A score set's variants are all assayed at one level, but some may be unmapped (null). Return the most
+// common non-null level, or null when nothing is mapped.
+export function dominantAssayLevel(levels: Array): SequenceLevel | null {
+ const counts = new Map()
+ for (const level of levels) {
+ if (level) counts.set(level, (counts.get(level) ?? 0) + 1)
+ }
+ let best: SequenceLevel | null = null
+ let bestCount = 0
+ for (const [level, count] of counts) {
+ if (count > bestCount) {
+ best = level
+ bestCount = count
+ }
+ }
+ return best
}
diff --git a/src/lib/notables.test.ts b/src/lib/notables.test.ts
new file mode 100644
index 00000000..7f4dd57b
--- /dev/null
+++ b/src/lib/notables.test.ts
@@ -0,0 +1,186 @@
+import {describe, expect, it} from 'vitest'
+
+import {
+ clinicalExtremesPerClass,
+ consequenceExemplars,
+ deviationsFromMedian,
+ medianAndMad,
+ scoreExtremes
+} from '@/lib/notables'
+import type {DisplayVariant} from '@/lib/variants'
+
+// Minimal variant factory — only the fields the notable samplers read.
+function v(
+ urn: string,
+ score: number | null,
+ extras: {consequence?: string | null; clnsig?: string; stars?: keyof typeof STARS; discordance?: string} = {}
+): DisplayVariant {
+ const variant = {variantUrn: urn, score, consequence: extras.consequence ?? null} as DisplayVariant
+ if (extras.clnsig) {
+ variant.control = {
+ clinicalSignificance: extras.clnsig,
+ clinicalReviewStatus: extras.stars ?? '1-star',
+ // Notables only headline clean/concordant controls; default to a clean call so the star/class logic
+ // is exercised, and let a test opt into 'soft'/'hard' to assert exclusion.
+ discordance: extras.discordance ?? 'none'
+ } as DisplayVariant['control']
+ }
+ return variant
+}
+
+// Review-status strings that map to the star ratings we care about (see CLINVAR_REVIEW_STATUS_STARS).
+const STARS = {
+ '0-star': 'no assertion criteria provided',
+ '1-star': 'criteria provided, single submitter',
+ '2-star': 'criteria provided, multiple submitters, no conflicts'
+} as const
+
+// Rewrite the factory's star shorthand into the real review-status strings.
+function withStars(variant: DisplayVariant): DisplayVariant {
+ const control = variant.control
+ if (!control || control.discordance === 'hard') return variant
+ const shorthand = control.clinicalReviewStatus as keyof typeof STARS | undefined
+ if (shorthand && STARS[shorthand]) {
+ ;(control as {clinicalReviewStatus: string}).clinicalReviewStatus = STARS[shorthand]
+ }
+ return variant
+}
+
+describe('clinicalExtremesPerClass', () => {
+ it('returns one exemplar per class, separated along the learned damaging direction', () => {
+ // Pathogenic scores low, benign high → damaging direction is negative. Expect the lowest P and
+ // highest B, not the middling members.
+ const variants = [
+ v('p-mid', -1, {clnsig: 'Pathogenic'}),
+ v('p-extreme', -3, {clnsig: 'Likely pathogenic'}),
+ v('b-mid', 1, {clnsig: 'Benign'}),
+ v('b-extreme', 3, {clnsig: 'Likely benign'})
+ ].map(withStars)
+ const result = clinicalExtremesPerClass(variants)
+ expect(result.map((r) => r.variantUrn)).toEqual(['p-extreme', 'b-extreme'])
+ })
+
+ it('learns the opposite polarity too (pathogenic high, benign low)', () => {
+ const variants = [
+ v('p-extreme', 5, {clnsig: 'Pathogenic'}),
+ v('p-mid', 2, {clnsig: 'Pathogenic'}),
+ v('b-extreme', -4, {clnsig: 'Benign'}),
+ v('b-mid', -1, {clnsig: 'Benign'})
+ ].map(withStars)
+ expect(clinicalExtremesPerClass(variants).map((r) => r.variantUrn)).toEqual(['p-extreme', 'b-extreme'])
+ })
+
+ it('falls back to furthest-from-median when only one class is present', () => {
+ const variants = [
+ v('p-near', 0.1, {clnsig: 'Pathogenic'}),
+ v('p-far', 4, {clnsig: 'Pathogenic'}),
+ v('unclassified-a', -2, {}),
+ v('unclassified-b', 0, {})
+ ].map(withStars)
+ const result = clinicalExtremesPerClass(variants)
+ // Set median is ~0.05; p-far (4) is furthest.
+ expect(result.map((r) => r.variantUrn)).toEqual(['p-far'])
+ })
+
+ it('excludes controls below the star threshold and VUS/unclassified', () => {
+ const variants = [
+ v('p-lowstar', -3, {clnsig: 'Pathogenic', stars: '0-star'}),
+ v('vus', -2, {clnsig: 'Uncertain significance', stars: '2-star'}),
+ v('b-ok', 3, {clnsig: 'Benign', stars: '1-star'})
+ ].map(withStars)
+ const result = clinicalExtremesPerClass(variants, 1)
+ // Only the benign passes → single-class fallback, benign returned.
+ expect(result.map((r) => r.variantUrn)).toEqual(['b-ok'])
+ })
+
+ it('excludes soft-conflict controls — a directional lean beside an uncertain record is not definitive', () => {
+ const variants = [
+ // Both carry a directional representative at a passing star, but the soft one is a soft conflict and
+ // must not headline; only the clean pathogenic exemplar survives (single-class fallback).
+ v('p-soft', -3, {clnsig: 'Pathogenic', stars: '2-star', discordance: 'soft'}),
+ v('p-clean', -1, {clnsig: 'Pathogenic', stars: '2-star', discordance: 'none'})
+ ].map(withStars)
+ expect(clinicalExtremesPerClass(variants, 1).map((r) => r.variantUrn)).toEqual(['p-clean'])
+ })
+
+ it('includes concordant controls — a same-direction agreement is still definitive', () => {
+ const variants = [
+ v('p-concordant', -4, {clnsig: 'Pathogenic', stars: '2-star', discordance: 'concordant'}),
+ v('b-ok', 3, {clnsig: 'Benign', stars: '1-star'})
+ ].map(withStars)
+ // Both classes present and usable → one exemplar each, pathogenic-then-benign.
+ expect(clinicalExtremesPerClass(variants, 1).map((r) => r.variantUrn)).toEqual(['p-concordant', 'b-ok'])
+ })
+
+ it('returns nothing when no definitive controls clear the star gate', () => {
+ const variants = [v('vus', 1, {clnsig: 'Uncertain significance', stars: '2-star'}), v('plain', 2)].map(withStars)
+ expect(clinicalExtremesPerClass(variants)).toEqual([])
+ })
+})
+
+describe('consequenceExemplars', () => {
+ it('returns one representative per present bucket in canonical order, skipping No consequence', () => {
+ const variants = [
+ v('syn-1', 1, {consequence: 'synonymous_variant'}),
+ v('mis-1', 2, {consequence: 'missense_variant'}),
+ v('mis-2', 3, {consequence: 'missense_variant'}),
+ v('non-1', 4, {consequence: 'stop_gained'}),
+ v('none-1', 5, {consequence: null})
+ ]
+ const result = consequenceExemplars(variants)
+ // Canonical order is Missense, Synonymous, Nonsense, … → first-seen rep of each, No consequence dropped.
+ expect(result.map((r) => r.variantUrn)).toEqual(['mis-1', 'syn-1', 'non-1'])
+ })
+
+ it('is empty when every variant lacks a consequence (truly-unmapped set)', () => {
+ const variants = [v('a', 1, {consequence: null}), v('b', 2, {consequence: 'NA'})]
+ expect(consequenceExemplars(variants)).toEqual([])
+ })
+
+ it('ignores unscored variants', () => {
+ const variants = [v('unscored', null, {consequence: 'missense_variant'}), v('scored', 1, {consequence: 'stop_gained'})]
+ expect(consequenceExemplars(variants).map((r) => r.variantUrn)).toEqual(['scored'])
+ })
+})
+
+describe('scoreExtremes', () => {
+ it('returns the n variants furthest from the median, both tails', () => {
+ const variants = [v('a', 0), v('b', 1), v('c', 2), v('d', 10), v('e', -8)]
+ // Median 1; distances: e=9, d=9, a=1, c=1, b=0. Top 2 are the two tails.
+ expect(scoreExtremes(variants, 2).map((r) => r.variantUrn).sort()).toEqual(['d', 'e'])
+ })
+
+ it('caps at the requested count and ignores unscored variants', () => {
+ const variants = [v('a', 0), v('b', 5), v('unscored', null)]
+ const result = scoreExtremes(variants, 5)
+ expect(result).toHaveLength(2)
+ expect(result.every((r) => typeof r.score === 'number')).toBe(true)
+ })
+
+ it('is empty on a set with no scored variants', () => {
+ expect(scoreExtremes([v('a', null)], 3)).toEqual([])
+ })
+})
+
+describe('medianAndMad / deviationsFromMedian', () => {
+ it('computes median and MAD', () => {
+ // Values 1,2,4,6,8: median 4; abs devs 3,2,0,2,4 → MAD 2.
+ expect(medianAndMad([1, 2, 4, 6, 8])).toEqual({median: 4, mad: 2})
+ })
+
+ it('reports signed deviations in MAD units', () => {
+ const spread = {median: 4, mad: 2}
+ expect(deviationsFromMedian(10, spread)).toBe(3)
+ expect(deviationsFromMedian(0, spread)).toBe(-2)
+ })
+
+ it('returns null deviations when the scale is degenerate (MAD 0)', () => {
+ const spread = medianAndMad([5, 5, 5, 5, 9])
+ expect(spread.mad).toBe(0)
+ expect(deviationsFromMedian(9, spread)).toBeNull()
+ })
+
+ it('handles an even-length array', () => {
+ expect(medianAndMad([1, 3]).median).toBe(2)
+ })
+})
diff --git a/src/lib/notables.ts b/src/lib/notables.ts
new file mode 100644
index 00000000..1c521914
--- /dev/null
+++ b/src/lib/notables.ts
@@ -0,0 +1,150 @@
+/**
+ * @fileoverview
+ * Notable-variant samplers for the score-set variant search's empty state.
+ *
+ * When the search box is empty the dropdown offers a few "interesting" variants to jump to instead of a
+ * blank list. Interest is graded by signal-richness, and each sampler here is one rung: clinical controls
+ * (richest, needs a fetch), consequence exemplars (intrinsic, mapping-derived), and score extremes (the
+ * universal floor — every score set has some variants with a score). A score set shows whichever rungs have data.
+ *
+ * These are PURE functions over the variant list so they stay trivially testable; the component groups
+ * their output into the AutoComplete and renders the captions.
+ */
+
+import {
+ BENIGN_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS,
+ CLINVAR_REVIEW_STATUS_STARS,
+ DEFAULT_CLNREVSTAT_FIELD,
+ DEFAULT_CLNSIG_FIELD,
+ DEFAULT_MIN_STAR_RATING,
+ PATHOGENIC_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS
+} from '@/lib/clinvar-controls'
+import type {UsableControlPlacement} from '@/lib/clinvar-control-placement'
+import {consequenceBucket, EFFECT_BUCKETS} from '@/lib/consequences'
+import type {DisplayVariant} from '@/lib/variants'
+
+/** Variants carrying a numeric score — the only ones with a point to jump to. */
+function scored(variants: DisplayVariant[]): DisplayVariant[] {
+ return variants.filter((v) => typeof v.score === 'number')
+}
+
+/**
+ * A control clean enough to headline a notables row: an unambiguous or concordant call. Soft conflicts (a
+ * directional lean beside an uncertain/Conflicting record) and hard discordance are excluded. Notables should
+ * be "definitive" exemplars, so we don't front a call we are hedging.
+ */
+function isDefinitiveControl(control: DisplayVariant['control']): control is UsableControlPlacement {
+ return control != null && (control.discordance === 'none' || control.discordance === 'concordant')
+}
+
+/** Star rating of a control's review status; -1 when the status is absent/unknown (never passes a ≥ gate). */
+function controlStars(variant: DisplayVariant): number {
+ const control = variant.control
+ const status = isDefinitiveControl(control) ? control[DEFAULT_CLNREVSTAT_FIELD] : undefined
+ return status != null ? (CLINVAR_REVIEW_STATUS_STARS[status] ?? -1) : -1
+}
+
+/**
+ * Clinical-control exemplars: one variant per definitive ClinVar class (P/LP, B/LB) at ≥ `minStar`,
+ * picked to show the assay separating the classes. When both classes are present the "damaging"
+ * direction is learned from the data (sign of the difference between the two class medians), and each
+ * class's exemplar is the variant furthest into its own end — the most functionally-extreme pathogenic
+ * and the most wild-type-like benign. When only one class is present there is no axis to separate, so
+ * that class's exemplar is simply the variant furthest from the whole set's median. Returns ≤2 rows, in
+ * pathogenic-then-benign order; empty when no definitive controls clear the star gate.
+ */
+export function clinicalExtremesPerClass(
+ variants: DisplayVariant[],
+ minStar: number = DEFAULT_MIN_STAR_RATING
+): DisplayVariant[] {
+ const definitive = (classes: string[]) =>
+ scored(variants).filter((v) => {
+ if (!isDefinitiveControl(v.control)) return false
+ return classes.includes(v.control[DEFAULT_CLNSIG_FIELD]) && controlStars(v) >= minStar
+ })
+ const pathogenic = definitive(PATHOGENIC_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS)
+ const benign = definitive(BENIGN_CLINICAL_SIGNIFICANCE_CLASSIFICATIONS)
+ if (!pathogenic.length && !benign.length) return []
+
+ // A direct call on the assayed allele is a stronger headliner than one projected from an encoding,
+ // so pick the exemplar from the direct members when a class has any; fall back to the whole (projected)
+ // group only when the class is present solely via the encodings.
+ const preferDirect = (group: DisplayVariant[]) => {
+ const direct = group.filter((v) => !v.control?.projected)
+ return direct.length ? direct : group
+ }
+
+ const scoreOf = (v: DisplayVariant) => v.score as number
+ const furthestFrom = (group: DisplayVariant[], anchor: number) =>
+ group.reduce((best, v) => (Math.abs(scoreOf(v) - anchor) > Math.abs(scoreOf(best) - anchor) ? v : best))
+
+ // Single-class fallback (median-furthest) is still a headline extreme, just without a contrast class.
+ if (pathogenic.length && benign.length) {
+ const pathMedian = median(pathogenic.map(scoreOf))
+ const benignMedian = median(benign.map(scoreOf))
+ const damagingWard = Math.sign(pathMedian - benignMedian) || 1
+ const mostWard = (group: DisplayVariant[], ward: number) =>
+ group.reduce((best, v) => (scoreOf(v) * ward > scoreOf(best) * ward ? v : best))
+ return [mostWard(preferDirect(pathogenic), damagingWard), mostWard(preferDirect(benign), -damagingWard)]
+ }
+ const anchor = median(scored(variants).map(scoreOf))
+ const group = pathogenic.length ? pathogenic : benign
+ return [furthestFrom(preferDirect(group), anchor)]
+}
+
+/**
+ * Consequence exemplars: one representative per VEP effect bucket present, in canonical bucket order — a
+ * quick sampler of the data's shape (a missense, a synonymous, a nonsense, …). 'No consequence' is
+ * skipped (the unmapped/unannotated pile is not a headline). Empty on truly-unmapped sets, where VEP
+ * consequence is absent for every variant.
+ */
+export function consequenceExemplars(variants: DisplayVariant[]): DisplayVariant[] {
+ const pool = scored(variants)
+ if (!pool.length) return []
+ const exemplars: DisplayVariant[] = []
+ for (const bucket of EFFECT_BUCKETS) {
+ if (bucket.name === 'No consequence') continue
+ const match = pool.find((v) => consequenceBucket(v.consequence) === bucket.name)
+ if (match) exemplars.push(match)
+ }
+ return exemplars
+}
+
+/**
+ * The `n` most extreme-scoring variants, ranked by distance from the median in robust (MAD) units.
+ * MAD-from-median is used instead of standard deviation because MAVE score distributions are typically
+ * bimodal — squaring residuals would let one far tail dominate the scale. Ranking by absolute deviation
+ * naturally surfaces both tails. The universal fallback rung: every scored variant qualifies.
+ */
+export function scoreExtremes(variants: DisplayVariant[], n: number): DisplayVariant[] {
+ const pool = scored(variants)
+ if (!pool.length) return []
+ const {median: med} = medianAndMad(pool.map((v) => v.score as number))
+ return [...pool].sort((a, b) => Math.abs((b.score as number) - med) - Math.abs((a.score as number) - med)).slice(0, n)
+}
+
+/** Median of a non-empty numeric array. Assumes at least one element. */
+function median(values: number[]): number {
+ const sorted = [...values].sort((a, b) => a - b)
+ const mid = Math.floor(sorted.length / 2)
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
+}
+
+/**
+ * Median and median absolute deviation of a score set — the robust center/scale pair the score-extremes
+ * caption reports against. `mad` is 0 when more than half the scores are identical (a degenerate scale);
+ * callers should treat that as "deviations not reportable".
+ */
+export function medianAndMad(scores: number[]): {median: number; mad: number} {
+ if (!scores.length) return {median: 0, mad: 0}
+ const med = median(scores)
+ return {median: med, mad: median(scores.map((s) => Math.abs(s - med)))}
+}
+
+/**
+ * Signed distance of a score from the median in MAD units ("N deviations from median"), for the
+ * score-extremes row caption. Null when the scale is degenerate (`mad` 0), where the count is meaningless.
+ */
+export function deviationsFromMedian(score: number, spread: {median: number; mad: number}): number | null {
+ return spread.mad === 0 ? null : (score - spread.median) / spread.mad
+}
diff --git a/src/lib/orcid.ts b/src/lib/orcid.ts
index 15405599..f4b6a698 100644
--- a/src/lib/orcid.ts
+++ b/src/lib/orcid.ts
@@ -33,6 +33,7 @@ import {v4 as uuidv4} from 'uuid'
import {computed, ref, Ref} from 'vue'
import config from '../config'
+import {clearReadCache} from '@/api/cache'
import {getErrorResponse} from '@/lib/errors'
export interface OidcUserProfileBase {
@@ -168,6 +169,10 @@ export async function continueAuthenticationFromRedirect() {
export function signOut() {
clearIdToken()
+ // Cached reads are keyed by content, not viewer, and some responses are viewer-scoped — drop them
+ // so the signed-out session cannot keep rendering the previous identity's view.
+ clearReadCache()
+
// clear any pending post-auth redirect when the user signs out manually.
try {
localStorage.removeItem('redirectAfterLogin')
diff --git a/src/lib/scores.test.ts b/src/lib/scores.test.ts
new file mode 100644
index 00000000..e64dc44e
--- /dev/null
+++ b/src/lib/scores.test.ts
@@ -0,0 +1,18 @@
+import {describe, expect, it} from 'vitest'
+
+import {formatScore, SCORE_DISPLAY_PRECISION} from './scores'
+
+describe('formatScore', () => {
+ it('renders a numeric score to the app-wide significant-figure precision', () => {
+ expect(formatScore(1.23456)).toBe((1.23456).toPrecision(SCORE_DISPLAY_PRECISION))
+ expect(formatScore(1.23456)).toBe('1.235')
+ // Trailing zeros are kept — sig figs, not decimal places.
+ expect(formatScore(-2)).toBe('-2.000')
+ expect(formatScore(0)).toBe('0.000')
+ })
+
+ it('returns null for a non-numeric (NA/absent) score, so callers own the empty rendering', () => {
+ expect(formatScore(null)).toBeNull()
+ expect(formatScore(undefined)).toBeNull()
+ })
+})
diff --git a/src/lib/scores.ts b/src/lib/scores.ts
index 6fba1e95..dac81760 100644
--- a/src/lib/scores.ts
+++ b/src/lib/scores.ts
@@ -5,6 +5,19 @@ export interface ScoresOrCountsRow {
[key: string]: any
}
+/** Significant figures used to display a functional score, app-wide. */
+export const SCORE_DISPLAY_PRECISION = 4
+
+/**
+ * Format a functional score for display — the single source of truth for the score's significant-figure
+ * precision, so every surface (search rows, histogram/heatmap tooltips, the detail panel, the variant
+ * page) reads it the same way. Returns null for a non-numeric (NA/absent) score, leaving each caller to
+ * decide how to render its absence (hide the field, "Not scored", a dash, …).
+ */
+export function formatScore(score: number | null | undefined): string | null {
+ return typeof score === 'number' ? score.toPrecision(SCORE_DISPLAY_PRECISION) : null
+}
+
/**
* Transform flat namespaced columns into nested objects.
* Converts columns like "namespace.colname" into nested structure { namespace: { colname: value } }
diff --git a/src/lib/tooltips.ts b/src/lib/tooltips.ts
new file mode 100644
index 00000000..ca1cc961
--- /dev/null
+++ b/src/lib/tooltips.ts
@@ -0,0 +1,106 @@
+// Shared builders for the d3-injected chart tooltips (histogram + heatmap).
+//
+// These tooltips are appended to document.body and rendered via d3 `.html()`, so they live
+// outside the Vue component tree: styling uses global Tailwind utilities (no scoped CSS would
+// reach them), and only data-driven colors are inline. Keep class strings literal so Tailwind's
+// scanner picks them up.
+
+/** Wrap composed sections in the tooltip root, or return null when there is nothing to show. */
+export function tooltipRoot(sections: (string | null | undefined)[]): string | null {
+ const html = sections.filter(Boolean).join('')
+ return html ? `${html}
` : null
+}
+
+// A tooltip section. Sections after the first are separated by a divider. Returns empty when it has no
+// content, so callers can compose optional sections without leaving stray dividers behind.
+export function tooltipSection(rows: (string | null | undefined)[]): string {
+ const body = rows.filter(Boolean).join('')
+ if (!body) {
+ return ''
+ }
+ return `${body}
`
+}
+
+/** A small, muted, uppercase section heading (e.g. "ClinVar", "Bin 1 to 1.05"). */
+export function tooltipSectionLabel(text: string): string {
+ return `${text}
`
+}
+
+export function tooltipTitle(text: string): string {
+ return `${text}
`
+}
+
+export function tooltipConsequence(text: string): string {
+ return `${text}
`
+}
+
+/** A muted, italicized aside (e.g. "Could not be mapped"). */
+export function tooltipNote(text: string): string {
+ return `${text}
`
+}
+
+export function tooltipFootnote(text: string): string {
+ return `*${text}
`
+}
+
+export function tooltipText(text: string): string {
+ return `${text}
`
+}
+
+export function tooltipEmptyLine(): string {
+ return `
`
+}
+
+export function tooltipKeyValue(label: string, value: string | number | null | undefined): string {
+ return `${label}: ${value ?? ''}
`
+}
+
+export function tooltipLink(href: string, text: string): string {
+ return `${text} `
+}
+
+/** ClinGen variant-details link — shared by both charts. */
+export function tooltipVariantDetailsLink(clingenAlleleId: string, variantUrn?: string | null): string {
+ const query = variantUrn ? `?variant=${encodeURIComponent(variantUrn)}` : ''
+ return tooltipLink(`/variants/${clingenAlleleId}${query}`, 'View variant details')
+}
+
+/** A round color swatch, used to tie series/legend colors to their labels. */
+export function tooltipSwatch(color: string): string {
+ return ` `
+}
+
+/** A colored, titled classification/shader badge. */
+export function tooltipBadge(color: string, text: string): string {
+ return `${text} `
+}
+
+/** A badge on its own line, spaced from the line above (e.g. a classification under a score). */
+export function tooltipBadgeBlock(color: string, text: string): string {
+ return `${tooltipBadge(color, text)}
`
+}
+
+/** A wrapping row of badges. */
+export function tooltipBadgeRow(badges: string[]): string {
+ return `${badges.join('')}
`
+}
+
+/** A `swatch · label · count` row. `active` bolds it (e.g. the hovered variant's series). */
+export function tooltipCountRow(options: {color: string; label: string; count: number; active?: boolean}): string {
+ const {color, label, count, active = false} = options
+ return (
+ `` +
+ tooltipSwatch(color) +
+ `${label ? label : 'No series label '} ` +
+ `${count} ` +
+ '
'
+ )
+}
+
+/** Four-star review rating, ClinVar-style (filled stars first). */
+export function tooltipReviewStars(numStars: number): string {
+ const filled = '★ '
+ const empty = '☆ '
+ const stars = new Array(4).fill(filled).fill(empty, numStars)
+ return `(${stars.join('')})`
+}
diff --git a/src/lib/variants.ts b/src/lib/variants.ts
index c6c89da2..0e1783b2 100644
--- a/src/lib/variants.ts
+++ b/src/lib/variants.ts
@@ -1,313 +1,62 @@
import _ from 'lodash'
-import {AMINO_ACIDS, AMINO_ACIDS_WITH_TER, singleLetterAminoAcidOrHgvsCode} from '@/lib/amino-acids'
-import {DEFAULT_CLNREVSTAT_FIELD, DEFAULT_CLNSIG_FIELD} from '@/lib/clinical-controls'
-import geneticCodes from '@/lib/genetic-codes'
-import {parseSimpleNtVariant, parseSimpleProVariant} from '@/lib/mave-hgvs'
-import {parseScoresOrCounts} from '@/lib/scores'
-import type {SimpleDnaVariation, SimpleProteinVariation} from '@/lib/mave-hgvs'
-
-export type HgvsReferenceSequenceType = 'c' | 'p' // | 'n'
-
-export interface SequenceRange {
- start: number
- length: number
-}
-
-export interface ClinicalControlVariant {
- [DEFAULT_CLNSIG_FIELD]: string
- [DEFAULT_CLNREVSTAT_FIELD]: string
-}
-
-type ParsedSimpleDnaVariation = SimpleDnaVariation & {
- residueType?: 'nt'
- origin?: 'mapped' | 'unmapped'
-}
-
-type ParsedSimpleProteinVariation = SimpleProteinVariation & {
- residueType?: 'aa'
- origin?: 'mapped' | 'unmapped'
-}
-
-export interface RawVariant {
- accession: string
- hgvs_nt?: string
- hgvs_pro?: string
- hgvs_splice?: string
-
- scores: {
- score: number | 'NA'
- [key: string]: any
- }
- counts?: {
- [key: string]: any
- }
- mavedb?: {
- post_mapped_hgvs_c?: string
- post_mapped_hgvs_p?: string
- post_mapped_vrs_id?: string
- }
- vep?: {
- vep_functional_consequence?: string
- }
- clingen?: {
- clingen_allele_id?: string
- }
- // The `gnomad` namespace. Numeric fields arrive as numbers via the CSV parser's dynamic typing, or
- // as the string 'NA' where the variant has no gnomAD record. Read via `gnomadFromVariantRow`.
- gnomad?: {
- gnomad_af?: number | string
- gnomad_ac?: number | string
- gnomad_an?: number | string
- gnomad_faf95_max?: number | string
- gnomad_faf95_max_ancestry?: string
- gnomad_id?: string
- gnomad_version?: string
- }
-
- control?: ClinicalControlVariant
- mavedb_label?: string
-}
-
-export interface VariantPropertiesAddedByPreparingCodingVariants {
- // Added by parseSimpleCodingVariants.
- parsedPostMappedHgvsC?: ParsedSimpleDnaVariation
- parsedPostMappedHgvsP?: ParsedSimpleProteinVariation
-}
-
-export interface Variant extends RawVariant, VariantPropertiesAddedByPreparingCodingVariants {
- // Added by translateSimpleCodingVariants
- translated_hgvs_p?: string
-}
-
-export const HGVS_REFERENCE_SEQUENCE_TYPES: Record<
- HgvsReferenceSequenceType,
- {parsedPostMappedHgvsField: keyof VariantPropertiesAddedByPreparingCodingVariants}
-> = {
- c: {
- parsedPostMappedHgvsField: 'parsedPostMappedHgvsC'
- },
- p: {
- parsedPostMappedHgvsField: 'parsedPostMappedHgvsP'
- }
-}
-
-export interface ParsedPostMappedVariantProperties {
- [type: string]: keyof VariantPropertiesAddedByPreparingCodingVariants
-}
-
-export const VARIANT_EFFECT_TYPE_OPTIONS = [
- {
- name: 'Synonymous',
- description: 'Show all synonymous variants',
- shortDescription: 'Synonymous variants'
- },
- {
- name: 'Missense',
- description: 'Show all missense variants',
- shortDescription: 'Missense variants'
- },
- {
- name: 'Nonsense',
- description: 'Show all nonsense variants',
- shortDescription: 'Nonsense variants'
- },
- {
- name: 'Start/Stop Loss',
- description: 'Show all start/stop loss variants',
- shortDescription: 'Start/Stop Loss variants'
- },
- {
- name: 'Other',
- description: 'Show all other variant types',
- shortDescription: 'Others'
- }
-]
-
-export const DEFAULT_VARIANT_EFFECT_TYPES = ['Missense', 'Nonsense', 'Synonymous', 'Other']
-
-export function parseScoreSetVariantData(csvData: string): Variant[] {
- const variants = parseScoresOrCounts(csvData, true) as Variant[]
- prepareScoreSetVariantData(variants)
- return variants
-}
-
-function prepareScoreSetVariantData(variants: Variant[]) {
- parseSimpleCodingVariants(variants)
- translateSimpleCodingVariants(variants)
-}
-
-export const PARSED_POST_MAPPED_VARIANT_PROPERTIES: ParsedPostMappedVariantProperties = {
- c: 'parsedPostMappedHgvsC',
- g: 'parsedPostMappedHgvsC',
- p: 'parsedPostMappedHgvsP'
-}
-
-function getParsedPostMappedHgvs(variant: Variant, type: HgvsReferenceSequenceType) {
- const field = PARSED_POST_MAPPED_VARIANT_PROPERTIES[type]
- return field ? variant[field] : undefined
-}
+import {singleLetterAminoAcidOrHgvsCode} from '@/lib/amino-acids'
+import type {ClinvarControlPlacement} from '@/lib/clinvar-control-placement'
+import type {components} from '@/schema/openapi'
/**
- * Add parsed post-mapped HGVS c. and p. strings to variants wherever possible.
- *
- * When a mapped c. or p. string is not present but unmapped c. or p. strings are present and have references, use them
- * instead. This is a temporary measure until we have more thorough access to mapped c. and p. strings.
- *
- * Notice that this function alters members of the variants array by adding parsedPostMappedHgvsC and
- * parsedPostMappedHgvsP properties.
- *
- * @param variants The variants to modify.
+ * The lean per-variant record served by `GET /score-sets/{urn}/variants`, mirrored from the API's
+ * OpenAPI schema. This is the shape the score-set and variant views read: the migration off the
+ * CSV-derived `Variant`/`RawVariant` types is complete and those types are gone.
*/
-function parseSimpleCodingVariants(variants: Variant[]) {
- for (const v of variants) {
- // Create the mavedb namespace if it doesn't exist.
- if (!v.mavedb) v.mavedb = {}
-
- if (v.mavedb.post_mapped_hgvs_c && v.mavedb.post_mapped_hgvs_c != 'NA') {
- const parsedHgvs = parseSimpleNtVariant(v.mavedb.post_mapped_hgvs_c)
- if (parsedHgvs && parsedHgvs.referenceType == 'c') {
- v.parsedPostMappedHgvsC = parsedHgvs
- v.parsedPostMappedHgvsC.residueType = 'nt'
- v.parsedPostMappedHgvsC.origin = 'mapped'
- }
- } else if (v.hgvs_nt && v.hgvs_nt != 'NA') {
- // If a mapped HGVS c. string is missing but the raw HGVS string is a c. string with reference, us it instead.
- const parsedHgvs = parseSimpleNtVariant(v.hgvs_nt)
- // Treat g. and n. the same as c. for now, and allow there to be no accession.
- if (parsedHgvs && ['c', 'g', 'n'].includes(parsedHgvs.referenceType)) {
- //} && parsedHgvs.target) {
- v.mavedb.post_mapped_hgvs_c = v.hgvs_nt
- v.parsedPostMappedHgvsC = parsedHgvs
- v.parsedPostMappedHgvsC.residueType = 'nt'
- v.parsedPostMappedHgvsC.origin = 'unmapped'
- }
- }
-
- if (v.mavedb.post_mapped_hgvs_p && v.mavedb.post_mapped_hgvs_p != 'NA') {
- const parsedHgvs = parseSimpleProVariant(v.mavedb.post_mapped_hgvs_p)
- if (parsedHgvs) {
- v.parsedPostMappedHgvsP = parsedHgvs
- v.parsedPostMappedHgvsP.residueType = 'aa'
- v.parsedPostMappedHgvsP.origin = 'mapped'
- }
- } else if (v.hgvs_pro && v.hgvs_pro != 'NA') {
- const parsedHgvs = parseSimpleProVariant(v.hgvs_pro)
- // Allow there to be no accession.
- if (parsedHgvs) {
- v.mavedb.post_mapped_hgvs_p = v.hgvs_pro
- v.parsedPostMappedHgvsP = parsedHgvs
- v.parsedPostMappedHgvsP.residueType = 'aa'
- v.parsedPostMappedHgvsP.origin = 'unmapped'
- }
- }
- }
-}
-
-export function filterVariantsForTargetInference(variants: any[]) {
- // Use p. variants unless there are c. variants that don't have p. strings.
- let referenceType: HgvsReferenceSequenceType = 'p'
- if (variants.some((v) => !v.parsedPostMappedHgvsP && v.parsedPostMappedHgvsC)) {
- referenceType = 'c'
- }
-
- // Filter on variants with the chosen HGVS string type.
- return {
- referenceType,
- variants: variants.filter((v) => getParsedPostMappedHgvs(v, referenceType))
- }
-}
+export type HgvsField = components['schemas']['HgvsField']
+export type LeanVariant = components['schemas']['LeanVariant']
/**
- * Determine the range of substitution positions in a set of variants. Ignore variants that are not simple
- * substitutions.
- *
- * This function presumes that any simple substitutions in the set of variants have their parsed HGVS
- * (parsedPostMappedHgvsC or parsedPostMappedHgvsP, depending on referenceType) property set.
- *
- * @param simpleVariants A list of variants
- * @param referenceType The type of reference (c for coding DNA nucleotide sequence or p for protein amino acid
- * sequence) with respect to which positions are given. This determines which HGVS property of the variants,
- * parsedPostMappedHgvsC or parsedPostMappedHgvsP, is used to obtain positions.
- * @returns An object with start and length properties representing the range of positions of variation. The length is
- * 0 if there are no variants.
+ * A lean variant as displayed on the score-set page, with the one client-side augmentation the views
+ * add on top of the API record: `control` (clinical-control data merged in by variant URN). Score-set
+ * visualizations and search consume this shape.
*/
-function getReferenceRange(variants: Variant[], referenceType: HgvsReferenceSequenceType): SequenceRange {
- // Assume that all variants have the same residue type and reference.
- if (variants.length == 0) {
- return {
- start: 0,
- length: 0
- }
- }
- const positionMin = _.min(variants.map((v) => getParsedPostMappedHgvs(v, referenceType)?.position)) ?? 0
- const positionMax = _.max(variants.map((v) => getParsedPostMappedHgvs(v, referenceType)?.position)) ?? 0
- return {
- start: positionMin,
- length: positionMax - positionMin + 1
- }
+export type DisplayVariant = LeanVariant & {
+ control?: ClinvarControlVariant | null
+}
+
+export interface SequenceRange {
+ start: number
+ length: number
}
/**
- * Infer a DNA or protein reference sequence from variants with parsed HGVS strings.
- *
- * This function looks at each variant's parsedPostMappedHgvsC or parsedPostMappedHgvsP property (depending on the
- * specified reference type) and constructs a DNA reference sequence from the references alleles, wherever the parsed
- * HGVS property is populated. The returned reference sequence is accompanied by an object specifying the range of
- * positions it describes. For instance, if the 5'-most variant is c.101A>C, then the reference sequence will begin with
- * "A," and range.start will be 101. For any position at which no reference allele can be found among the variants, the
- * reference will have an "N" (for DNA sequences) or an "X" (for protein sequences).
- *
- * If no variants have their parsed HGVS property set, then an empty coding sequence is returned.
- *
- * @param variants An array of variants from which to infer a coding sequence.
- * @param referenceType The HGVS reference type, which may be "c" or "p." Any other reference types, including "g" and
- * "n," will yield an empty reference sequence.
- * @returns TODO
+ * The clinical-control facet merged onto a variant: the divergence fold's placement
+ * ({@link ClinvarControlPlacement}) — representative call for one-label surfaces, plus the winning-set
+ * classifications and the excluded/directional flags the histogram bins off.
*/
-export function inferReferenceSequenceFromVariants(variants: Variant[], referenceType: HgvsReferenceSequenceType) {
- if (variants.length == 0 || !['c', 'p'].includes(referenceType)) {
- return {
- referenceSequence: '',
- referenceSequenceResidueType: referenceType == 'p' ? 'aa' : 'nt',
- referenceSequenceRange: {start: 0, length: 0}
- }
- }
- const referenceSequenceRange = getReferenceRange(variants, referenceType)
- const unknownResidue = referenceType == 'p' ? 'X' : 'N'
- const referenceSequenceArr = Array(referenceSequenceRange.length).fill(unknownResidue)
- for (const variant of variants) {
- const parsedHgvs = getParsedPostMappedHgvs(variant, referenceType)
- if (!parsedHgvs || parsedHgvs.position == null) {
- continue
- }
- if (referenceSequenceArr[parsedHgvs.position - referenceSequenceRange.start] == unknownResidue) {
- const referenceAllele = parsedHgvs.original
- const referenceAllele1Char =
- referenceType == 'p' ? singleLetterAminoAcidOrHgvsCode(referenceAllele) : referenceAllele
- if (referenceAllele1Char != null) {
- referenceSequenceArr[parsedHgvs.position - referenceSequenceRange.start] = referenceAllele1Char
+export type ClinvarControlVariant = ClinvarControlPlacement
+
+export function inferReferenceSequenceFromBlocks(
+ variants: DisplayVariant[],
+ getBlock: (variant: DisplayVariant) => HgvsField | null,
+ residueType: 'nt' | 'aa'
+): {referenceSequence: string; referenceSequenceRange: SequenceRange} {
+ const blocks = variants.map(getBlock).filter((block): block is HgvsField => block != null && block.position != null)
+ if (blocks.length == 0) {
+ return {referenceSequence: '', referenceSequenceRange: {start: 0, length: 0}}
+ }
+ const start = _.min(blocks.map((block) => block.position!))!
+ const end = _.max(blocks.map((block) => block.position!))!
+ const length = end - start + 1
+ const unknownResidue = residueType == 'aa' ? 'X' : 'N'
+ const referenceSequenceArr = Array(length).fill(unknownResidue)
+ for (const block of blocks) {
+ const index = block.position! - start
+ if (referenceSequenceArr[index] == unknownResidue && block.ref != null) {
+ const oneChar = residueType == 'aa' ? singleLetterAminoAcidOrHgvsCode(block.ref) : block.ref
+ if (oneChar != null) {
+ referenceSequenceArr[index] = oneChar
}
- // Uncomment to validate that all reference alleles at a position are identical. To do this, we also have to move
- // the definition of referenceAllele1Char up, and we wind up running singleLetterAminoAcidOrHgvsCode for many more
- // variants.
-
- // } else if (referenceAllele1Char != referenceSequenceArr[parsedHgvs.position - referenceSequenceRange.start]) {
- // console.log(
- // `WARNING: Two variants with simple HGVS strings have different reference alleles at position ${parsedHgvs.position}.`
- // )
- // return {
- // referenceSequence: '',
- // referenceSequenceResidueType: referenceType == 'p' ? 'aa' : 'nt',
- // referenceSequenceRange: {start: 0, length: 0}
- // }
}
}
- return {
- referenceSequence: referenceSequenceArr.join(''),
- referenceSequenceResidueType: referenceType == 'p' ? 'aa' : 'nt',
- referenceSequenceRange
- }
+ return {referenceSequence: referenceSequenceArr.join(''), referenceSequenceRange: {start, length}}
}
/**
@@ -333,128 +82,30 @@ export function inferReferenceSequenceFromVariants(variants: Variant[], referenc
*
* @param variants The array of variants to translate.
*/
-function translateSimpleCodingVariants(variants: Variant[]) {
- const {referenceSequence: codingSequence, referenceSequenceRange: codingSequenceRange} =
- inferReferenceSequenceFromVariants(variants, 'c')
- if (codingSequence.length > 0) {
- for (const v of variants) {
- // We can only translate c. variants.
- if (!v.parsedPostMappedHgvsP && v.parsedPostMappedHgvsC && v.parsedPostMappedHgvsC.referenceType == 'c') {
- const translatedHgvsP = translateSimpleCodingHgvsCVariant(
- v.parsedPostMappedHgvsC,
- codingSequence,
- codingSequenceRange
- )
- if (translatedHgvsP) {
- const parsedHgvsP = parseSimpleProVariant(translatedHgvsP)
- if (parsedHgvsP) {
- v.translated_hgvs_p = translatedHgvsP
- v.parsedPostMappedHgvsP = parsedHgvsP
- }
- }
- }
- }
- }
+function proteinConsequenceBlock(variant: DisplayVariant): HgvsField | null {
+ return variant.mapped?.protein ?? variant.hgvsPro ?? null
}
/**
- * Translate one simple coding DNA variant.
- *
- * @param parsedHgvsC The variant's parsed HGVS "c." string.
- * @param codingReferenceSequence All or part of the DNA reference sequence from an open reading frame. The variant's
- * reference allele is assumed to agree with the reference and is not checked.
- * @param codingSequenceRange The range of nucleotide positions represented by the refernce sequence, relative to the
- * reference used by the parsed HGVS expression. If the reference contains the whole ORF, then this will be 1, but
- * it may be higher if the reference only represents part of the ORF.
- * @returns
- */
-function translateSimpleCodingHgvsCVariant(
- parsedHgvsC: SimpleDnaVariation,
- codingReferenceSequence: string,
- codingReferenceSequenceRange: SequenceRange
-) {
- if (parsedHgvsC.position == null) {
- return undefined
- }
- const offsetInCodon = (parsedHgvsC.position - 1) % 3
- const codonStartPosition = parsedHgvsC.position - offsetInCodon
- const aaPosition = Math.floor((codonStartPosition - 1) / 3) + 1
- if (codonStartPosition < codingReferenceSequenceRange.start) {
- return undefined
- }
- const codon = codingReferenceSequence.substring(
- codonStartPosition - codingReferenceSequenceRange.start,
- codonStartPosition - codingReferenceSequenceRange.start + 3
- )
- if (codon.length != 3 || codon.includes('N')) {
- return undefined
- }
- const codonArr = codon.split('')
- codonArr[offsetInCodon] = parsedHgvsC.substitution
- const variantCodon = codonArr.join('')
- // @ts-expect-error codonToAa is not reflected in the type yet
- const originalAaResidue = geneticCodes.standard.dna.codonToAa[codon]
- // @ts-expect-error codonToAa is not reflected in the type yet
- const variantAaResidue = geneticCodes.standard.dna.codonToAa[variantCodon]
- const originalAaTriple = _.startCase(
- AMINO_ACIDS_WITH_TER.find((aa) => aa.codes.single == originalAaResidue)?.codes?.triple?.toLowerCase()
- )
- const variantAaTriple = _.startCase(
- AMINO_ACIDS_WITH_TER.find((aa) => aa.codes.single == variantAaResidue)?.codes?.triple?.toLowerCase()
- )
- return `p.${originalAaTriple}${aaPosition}${variantAaTriple}`
-}
-
-/**
- * Determines whether a given variant represents either a start-loss (loss of the initiator methionine)
- * or a stop-loss (loss of a terminal stop/termination signal) event based on its protein-level HGVS notation.
- *
- * Detection logic:
- * 1. Selects the first available, non-null / non-"NA" protein HGVS string from:
- * - variant.post_mapped_hgvs_p
- * - variant.hgvs_pro_inferred
- * - variant.hgvs_pro
- * 2. Parses the HGVS protein string via parseSimpleProVariant (external utility).
- * 3. Returns:
- * - true if the variant alters the initiator methionine at position 1 (original == 'Met') to a different residue.
- * - true if the variant alters a termination symbol at position 1 (original == 'Ter' or '*') to a non-stop residue.
- * 4. Returns false if no suitable HGVS string is found, parsing fails, or the criteria above are not met.
+ * Whether a variant is a start-loss (loss of the initiator methionine) or stop-loss event.
*
- * Notes:
- * - The function currently infers start-loss strictly when position == 1 and original is 'Met'.
- *
- *
- * Parameter requirements:
- * - variant should be an object containing at least one of the HGVS protein fields listed above.
- * - External helpers required: variantNotNullOrNA, parseSimpleProVariant.
- *
- * @param variant Arbitrary variant-like object holding HGVS protein annotations.
- * @returns true if the variant is classified as start-loss or stop-loss; false (or undefined) otherwise.
+ * Prefers the VEP consequence when present; otherwise reads the protein block off the lean record and
+ * decides on the residues themselves. Used as the heatmap's plotted-representation filter, where VEP may
+ * be absent and the amino-acid change being drawn is the right signal.
*/
-export function isStartOrStopLoss(variant: any) {
- if (variant.vep && variant.vep.vep_functional_consequence && variant.vep.vep_functional_consequence != 'NA') {
- if (
- variant.vep.vep_functional_consequence == 'start_lost' ||
- variant.vep.vep_functional_consequence == 'stop_lost'
- ) {
- return true
- } else {
- return false
- }
+export function isStartOrStopLoss(variant: DisplayVariant): boolean {
+ if (variant.consequence && variant.consequence != 'NA') {
+ return variant.consequence == 'start_lost' || variant.consequence == 'stop_lost'
}
- const parsedVariant = variant.parsedPostMappedHgvsP
- if (!parsedVariant) {
+ const block = proteinConsequenceBlock(variant)
+ if (!block || block.ref == null || block.alt == null) {
return false
}
- if (parsedVariant.position == 1 && parsedVariant.original == 'Met' && parsedVariant.substitution != 'Met') {
+ if (block.position == 1 && block.ref == 'Met' && block.alt != 'Met') {
// Start loss
return true
}
- if (
- (parsedVariant.original == 'Ter' || parsedVariant.original == '*') &&
- parsedVariant.substitution != 'Ter' &&
- parsedVariant.substitution != '*'
- ) {
+ if ((block.ref == 'Ter' || block.ref == '*') && block.alt != 'Ter' && block.alt != '*') {
// Stop loss
return true
}
@@ -462,85 +113,7 @@ export function isStartOrStopLoss(variant: any) {
return false
}
-export function variantIsMissense(variant: Variant) {
- if (variant.vep && variant.vep.vep_functional_consequence && variant.vep.vep_functional_consequence != 'NA') {
- if (variant.vep.vep_functional_consequence == 'missense_variant') {
- return true
- } else {
- return false
- }
- }
- const parsedVariant = variant.parsedPostMappedHgvsP
- if (!parsedVariant) {
- return false
- }
- const refAllele = parsedVariant.original.toUpperCase()
- const altAllele = parsedVariant.substitution.toUpperCase()
- const refAlleleIsAA = AMINO_ACIDS.find((aa) => aa.codes.triple == refAllele)
- const altAlleleIsAA = AMINO_ACIDS.find((aa) => aa.codes.triple == altAllele)
- const startLoss = parsedVariant.position == 1 && refAllele == 'MET'
- return !!(refAlleleIsAA && altAlleleIsAA && !startLoss && refAllele != altAllele)
-}
-
-export function variantIsSynonymous(variant: Variant) {
- if (variant.vep && variant.vep.vep_functional_consequence && variant.vep.vep_functional_consequence != 'NA') {
- if (variant.vep.vep_functional_consequence == 'synonymous_variant') {
- return true
- } else {
- return false
- }
- }
- const parsedVariant = variant.parsedPostMappedHgvsP
- if (!parsedVariant) {
- return false
- }
- const refAllele = parsedVariant.original.toUpperCase()
- const altAllele = parsedVariant.substitution.toUpperCase()
- const refAlleleIsAA = AMINO_ACIDS.find((aa) => aa.codes.triple == refAllele)
- return !!(refAlleleIsAA && (refAllele == altAllele || altAllele == '='))
-}
-
-export function variantIsNonsense(variant: Variant) {
- if (variant.vep && variant.vep.vep_functional_consequence && variant.vep.vep_functional_consequence != 'NA') {
- if (variant.vep.vep_functional_consequence == 'stop_gained') {
- return true
- } else {
- return false
- }
- }
- const parsedVariant = variant.parsedPostMappedHgvsP
- if (!parsedVariant) {
- return false
- }
- const altAllele = parsedVariant.substitution.toUpperCase()
- return altAllele == 'TER' || altAllele == '*'
-}
-
-export function variantIsOther(variant: Variant) {
- return (
- !variantIsMissense(variant) &&
- !variantIsSynonymous(variant) &&
- !variantIsNonsense(variant) &&
- !isStartOrStopLoss(variant)
- )
-}
-
-/**
- * Check that this application is able to determine the protein consequence of every variant.
- *
- * This means that every variant either has a parseable protein HGVS string or is known to be non-coding.
- *
- * Here we distinguish known a non-coding variant by the facts that (a) it has a parsed HGVS c. string and (b) the
- * position in this string is not an integer.
- *
- * @param variants A list of variants.
- * @returns True if every variant has a protein consequence determinable by this application.
- */
-export function allCodingVariantsHaveProteinConsequence(variants: Variant[]) {
- return variants.every(
- (v) =>
- (v.vep && v.vep.vep_functional_consequence && v.vep.vep_functional_consequence != 'NA') ||
- v.parsedPostMappedHgvsP != null ||
- (v.parsedPostMappedHgvsC?.referenceType == 'c' && v.parsedPostMappedHgvsC?.position == null)
- )
-}
+// Protein-effect classification by VEP consequence now lives in `lib/consequences.ts`
+// (`consequenceBucket`). `isStartOrStopLoss` above is intentionally block-aware and stays here: it is
+// the heatmap's plotted-representation filter (hiding start/stop-loss cells on synthetic targets),
+// where VEP may be absent and the amino-acid change being drawn is the right signal.
diff --git a/src/main.js b/src/main.js
index d22af0d1..a66eee7a 100644
--- a/src/main.js
+++ b/src/main.js
@@ -16,6 +16,7 @@ import {installAxiosAuthHeaderInterceptor, installAxiosUnauthorizedResponseInter
import {initializeAuthentication as initializeOrcidAuthentication} from '@/lib/orcid'
import router from '@/router'
import store from '@/store'
+import {vKeyTerm} from '@/directives/key-term'
import 'primeicons/primeicons.css'
@@ -224,7 +225,26 @@ const MaveDbTheme = definePreset(Aura, {
}
},
},
- }
+ },
+ tooltip: {
+ root: {
+ borderRadius: '6px',
+ padding: '0.5rem 0.75rem',
+ maxWidth: '18rem',
+ },
+ colorScheme: {
+ // Solid brand sage (--color-sage-dark), white text, matching sage
+ // arrow (it follows the background token). Same in light and dark.
+ light: {root: {background: '#5a9375', color: '#ffffff'}},
+ dark: {root: {background: '#5a9375', color: '#ffffff'}},
+ },
+ // font-family isn't a token, so the brand body font rides along here.
+ css: () => `
+ .p-tooltip-text {
+ font-family: var(--font-body);
+ }
+ `,
+ },
},
})
@@ -246,6 +266,7 @@ createApp(App)
.use(ConfirmationService)
.use(ToastService)
.directive('tooltip', Tooltip)
+ .directive('key-term', vKeyTerm)
.mount('#app')
// Add the FontAwesome icons to the library so that they can be used in components.
diff --git a/src/router/index.ts b/src/router/index.ts
index 46ab4b41..3df910cb 100644
--- a/src/router/index.ts
+++ b/src/router/index.ts
@@ -178,7 +178,9 @@ const routes: RouteRecordRaw[] = [
name: 'variant',
component: VariantScreen,
props: (route) => ({
- clingenAlleleId: route.params.clingenAlleleId
+ clingenAlleleId: route.params.clingenAlleleId,
+ // `?variant=` highlights a specific measurement without changing the CAID/PAID anchor.
+ highlightVariantUrn: (Array.isArray(route.query.variant) ? route.query.variant[0] : route.query.variant) || null
})
},
{
@@ -190,10 +192,14 @@ const routes: RouteRecordRaw[] = [
async beforeEnter(to) {
const urn = Array.isArray(to.params.urn) ? to.params.urn[0] : to.params.urn
try {
+ // The detail envelope carries the assay-level ClinGen id as a flat field (the URN→allele
+ // bridge), so resolving a legacy measurement URN to its allele page is a direct read.
const detail = await getVariantDetail(urn)
- const mapped = detail.mappedVariants.find((m) => m.current)
- if (mapped?.clingenAlleleId) {
- return {name: 'variant', params: {clingenAlleleId: mapped.clingenAlleleId}}
+
+ // Carry the measurement URN as the `?variant=` highlight so the redirect preserves which
+ // measurement the cited link pointed at.
+ if (detail.clingenAlleleId) {
+ return {name: 'variant', params: {clingenAlleleId: detail.clingenAlleleId}, query: {variant: urn}}
}
} catch {
// Fall through to 404 if the variant can't be resolved
diff --git a/src/schema/openapi.d.ts b/src/schema/openapi.d.ts
index 160a0398..e6b5c3cf 100644
--- a/src/schema/openapi.d.ts
+++ b/src/schema/openapi.d.ts
@@ -31,6 +31,27 @@ export interface paths {
*/
delete: operations["delete_my_access_key_api_v1_users_me_access_keys__key_id__delete"];
};
+ "/api/v1/alleles/{identifier}": {
+ /**
+ * Fetch allele detail by VRS digest, CAID, or PAID
+ * @description Fetch the detail envelope for a deduplicated allele, by any of its identifiers.
+ *
+ * The allele-grain counterpart of ``GET /variants/{urn}``. Flat anchor identity (digest, level, HGVS,
+ * ClinGen id, spec-pure VRS) plus the cross-layer equivalence class (each member labelled relative to
+ * the focus) and a digest-keyed annotation map. The ``identifier`` may be:
+ *
+ * - a **VRS digest** (``ga4gh:VA.…``) — focuses that one allele.
+ * - a **CAID** (``CA…``) — the nt-canonical change; The coding frame is the preferential focus,
+ * falling back to the genomic frame if no coding frame exists.
+ * - a **PAID** (``PA…``) — the protein change; the protein allele is focused and its nucleotide
+ * equivalents surface as reverse-translation candidates.
+ *
+ * This is a **public molecular resource**. It carries no score-set-level information. No scores,
+ * classifications, measurements, or version standing. Only the allele's own identity, its cross-layer
+ * equivalence class, and public reference annotations (VEP / gnomAD / ClinVar).
+ */
+ get: operations["get_allele_api_v1_alleles__identifier__get"];
+ };
"/api/v1/api/version": {
/**
* Show API version
@@ -38,6 +59,17 @@ export interface paths {
*/
get: operations["show_version_api_v1_api_version_get"];
};
+ "/api/v1/clingen-alleles/{clingen_allele_id}/measurements": {
+ /**
+ * List measurements for a ClinGen allele's equivalence class
+ * @description List every measurement whose cross-layer equivalence class touches this ClinGen allele (a ``CA`` or
+ * ``PA``) — the direct measurements assayed at this change plus the reverse-translation-related ones,
+ * each labeled by its assayed level and relationship. This is the ClinGen-allele-centric variant page's
+ * entrypoint. A private score set's measurement is never included; its inline classification is withheld
+ * where the calibration is unreadable while the measurement still shows.
+ */
+ get: operations["get_clingen_allele_measurements_api_v1_clingen_alleles__clingen_allele_id__measurements_get"];
+ };
"/api/v1/users/me/collections": {
/**
* List my collections
@@ -231,6 +263,10 @@ export interface paths {
/**
* Validate a provided variant
* @description Validate the provided HGVS variant string.
+ *
+ * Parsing and validation failures both stem from caller-supplied input, so any ``HGVSError`` — a syntactic
+ * parse failure, an inconsistent variant, an unknown accession — is surfaced as a 400 rather than escaping
+ * to the catch-all 500 handler.
*/
post: operations["hgvs_validate_api_v1_hgvs_validate_post"];
};
@@ -320,38 +356,47 @@ export interface paths {
};
"/api/v1/mapped-variants/{urn}": {
/**
- * Fetch mapped variant by URN
- * @description Fetch a single mapped variant by URN.
+ * Moved to GET /variants/{urn}
+ * @deprecated
+ * @description This resource has moved. Use ``GET /variants/{urn}`` instead.
*/
- get: operations["show_mapped_variant_api_v1_mapped_variants__urn__get"];
+ get: operations["redirect_mapped_variant_api_v1_mapped_variants__urn__get"];
};
"/api/v1/mapped-variants/{urn}/va/study-result": {
/**
- * Construct a VA-Spec StudyResult from a mapped variant
- * @description Construct a single VA-Spec StudyResult from a mapped variant by URN.
+ * Moved to GET /variants/{urn}/va/study-result
+ * @deprecated
+ * @description This resource has moved. Use ``GET /variants/{urn}/va/study-result`` instead.
*/
- get: operations["show_mapped_variant_study_result_api_v1_mapped_variants__urn__va_study_result_get"];
+ get: operations["redirect_mapped_variant_study_result_api_v1_mapped_variants__urn__va_study_result_get"];
};
"/api/v1/mapped-variants/{urn}/va/functional-statement": {
/**
- * Construct a VA-Spec Statement from a mapped variant
- * @description Construct a single VA-Spec Statement from a mapped variant by URN.
+ * Moved to GET /variants/{urn}/va/functional-statement
+ * @deprecated
+ * @description This resource has moved. Use ``GET /variants/{urn}/va/functional-statement`` instead.
*/
- get: operations["show_mapped_variant_functional_impact_statement_api_v1_mapped_variants__urn__va_functional_statement_get"];
+ get: operations["redirect_mapped_variant_functional_impact_statement_api_v1_mapped_variants__urn__va_functional_statement_get"];
};
"/api/v1/mapped-variants/{urn}/va/pathogenicity-statement": {
/**
- * Construct a VA-Spec EvidenceLine from a mapped variant
- * @description Construct a list of VA-Spec EvidenceLine(s) from a mapped variant by URN.
+ * Moved to GET /variants/{urn}/va/pathogenicity-statement
+ * @deprecated
+ * @description This resource has moved. Use ``GET /variants/{urn}/va/pathogenicity-statement`` instead.
*/
- get: operations["show_mapped_variant_acmg_evidence_line_api_v1_mapped_variants__urn__va_pathogenicity_statement_get"];
+ get: operations["redirect_mapped_variant_acmg_evidence_line_api_v1_mapped_variants__urn__va_pathogenicity_statement_get"];
};
"/api/v1/mapped-variants/vrs/{identifier}": {
/**
- * Fetch mapped variants by VRS identifier
- * @description Fetch a single mapped variant by GA4GH identifier.
+ * Moved to GET /variants/vrs/{identifier}
+ * @deprecated
+ * @description This resource has moved. Use ``GET /variants/vrs/{identifier}`` instead.
+ *
+ * Note that the replacement's ``only_current`` boolean query parameter has been superseded by
+ * ``as_of``; a caller relying on ``only_current=false`` should switch to passing an explicit
+ * ``as_of`` timestamp rather than expecting it to carry over through this redirect.
*/
- get: operations["show_mapped_variants_by_identifier_api_v1_mapped_variants_vrs__identifier__get"];
+ get: operations["redirect_mapped_variants_by_identifier_api_v1_mapped_variants_vrs__identifier__get"];
};
"/api/v1/orcid/users/{orcid_id}": {
/**
@@ -495,7 +540,10 @@ export interface paths {
"/api/v1/score-calibrations/me": {
/**
* List my calibrations
- * @description List all score calibrations created by the current user.
+ * @description List the score calibrations created by the current user that the user may still read.
+ *
+ * Calibrations on score sets the user can no longer read, for example after being removed as a
+ * contributor, are omitted.
*/
get: operations["list_my_calibrations_api_v1_score_calibrations_me_get"];
};
@@ -670,6 +718,8 @@ export interface paths {
/**
* Publish Score Calibration Route
* @description Publish a score calibration, making it publicly visible.
+ *
+ * The calibration's score set must already be published.
*/
post: operations["publish_score_calibration_route_api_v1_score_calibrations__urn__publish_post"];
};
@@ -793,6 +843,52 @@ export interface paths {
*/
get: operations["get_score_set_csv_namespaces_api_v1_score_sets__urn__csv_namespaces_get"];
};
+ "/api/v1/score-sets/{urn}/variants": {
+ /**
+ * Get the lean whole-set variant view for a score set
+ * @description Return the lean whole-set view for a score set: one pre-chewed record per variant carrying the
+ * selection key (variant URN), score, a representative consequence, the bridge identifiers into the
+ * annotation dimensions (ClinGen allele id, assay-level digest), and the DNA + protein parsed
+ * position/ref/alt blocks that drive the heatmap's level toggle.
+ *
+ * The full set is returned in one payload — the score-set page bins/sorts/filters across every
+ * variant client-side. as_of time-travels the annotation layer only (scores are immutable); the
+ * resolved value is echoed in the X-As-Of response header so the content-time is a visible fact.
+ */
+ get: operations["get_score_set_lean_variants_api_v1_score_sets__urn__variants_get"];
+ };
+ "/api/v1/score-sets/{urn}/variant-details": {
+ /**
+ * Download a score set's variant details (VRS + Cat-VRS + annotations)
+ * @description Download the score set's variant details — the whole-set streaming pair of the single-variant
+ * ``GET /variants/{urn}`` detail endpoint, and the substrate-faithful replacement for the retired
+ * ``/mapped-variants`` export.
+ *
+ * One record per *mapped* variant (unmapped variants carry no VRS and are omitted): the same
+ * VariantDetail envelope the single-variant route serves — the flat ``preMapped``/``postMapped`` VRS
+ * pair for VRS consumers, plus the spec-pure GA4GH CategoricalVariant and the digest-keyed
+ * VEP/gnomAD/ClinVar annotation map for the full molecular picture.
+ *
+ * Streamed as NDJSON (like the annotated-variant exports) so a large score set downloads without
+ * materializing every envelope server-side and a client can process it line by line. ``as_of``
+ * time-travels the molecular layer only (scores/classifications are immutable); the resolved value is
+ * echoed in ``X-As-Of`` and the variant count in ``X-Total-Count``.
+ */
+ get: operations["get_score_set_variant_details_api_v1_score_sets__urn__variant_details_get"];
+ };
+ "/api/v1/score-sets/{urn}/mapped-variants": {
+ /**
+ * Removed; see GET /score-sets/{urn}/variant-details
+ * @deprecated
+ * @description This endpoint has been permanently removed.
+ *
+ * Its JSON-array response has been replaced by a streaming NDJSON payload with a different
+ * field shape (flat ``preMapped``/``postMapped`` VRS pair rather than a ``MappedVariant``-keyed
+ * record), so the two are not wire-compatible and this route does not redirect. Use
+ * ``GET /score-sets/{urn}/variant-details`` instead.
+ */
+ get: operations["get_score_set_mapped_variants_removed_api_v1_score_sets__urn__mapped_variants_get"];
+ };
"/api/v1/score-sets/{urn}/variants/data": {
/**
* Get score set variant data in CSV format
@@ -866,28 +962,21 @@ export interface paths {
*/
get: operations["get_score_set_counts_csv_api_v1_score_sets__urn__counts_get"];
};
- "/api/v1/score-sets/{urn}/mapped-variants": {
- /**
- * Get mapped variants from score set by URN
- * @description Return mapped variants from a score set, identified by URN.
- */
- get: operations["get_score_set_mapped_variants_api_v1_score_sets__urn__mapped_variants_get"];
- };
"/api/v1/score-sets/{urn}/annotated-variants/pathogenicity-statement": {
/**
- * Get pathogenicity statement annotations for mapped variants within a score set
+ * Get pathogenicity statement annotations for variants within a score set
* @description Retrieve annotated variants with pathogenicity statements for a given score set.
*
- * This endpoint streams pathogenicity evidence lines for all current mapped variants
+ * This endpoint streams pathogenicity evidence lines for all current annotated variants
* associated with a specific score set. The response is returned as newline-delimited
* JSON (NDJSON) format for efficient processing of large datasets.
*
* NDJSON Response Format:
- * Each line corresponds to a mapped variant and contains a JSON object with the following
- * structure:
+ * Each line in the response corresponds to an annotated variant and contains a JSON
+ * object with the following structure:
* ```
* {
- * "variant_urn": "",
+ * "variant_urn": "",
* "annotation": {
* ... // Pathogenicity evidence line details
* }
@@ -899,7 +988,7 @@ export interface paths {
* truncating the stream, and carries an additional `error` object:
* ```
* {
- * "variant_urn": "",
+ * "variant_urn": "",
* "annotation": null,
* "error": {"type": "", "detail": ""}
* }
@@ -917,36 +1006,39 @@ export interface paths {
*
* Returns:
* Any: StreamingResponse containing newline-delimited JSON with pathogenicity
- * evidence lines for each mapped variant. Response includes headers with
+ * evidence lines for each annotated variant. Response includes headers with
* total count, processing start time, and stream type information.
*
+ * A score set that exists but has no annotatable variants (never mapped, or none live at ``as_of``)
+ * streams an empty body with ``X-Total-Count: 0`` — an empty collection, not a 404.
+ *
* Raises:
* HTTPException: 404 error if the score set with the given URN is not found.
- * HTTPException: 404 error if no mapped variants are associated with the score set.
* HTTPException: 403 error if the user lacks READ permissions for the score set.
*
* Note:
* This function logs the request context and validates user permissions before
- * processing. Only current (non-historical) mapped variants are included in
- * the response.
+ * processing. Use the `as_of` parameter to reconstruct the molecular layer as it stood at a specific
+ * instant, over the variant's fixed score. The response is streamed to allow for efficient handling
+ * of large datasets, and progress updates are logged for monitoring purposes.
*/
get: operations["get_score_set_annotated_variants_api_v1_score_sets__urn__annotated_variants_pathogenicity_statement_get"];
};
"/api/v1/score-sets/{urn}/annotated-variants/functional-statement": {
/**
- * Get functional impact statement annotations for mapped variants within a score set
+ * Get functional impact statement annotations for annotated variants within a score set
* @description Retrieve functional impact statements for annotated variants in a score set.
*
- * This endpoint streams functional impact statements for all current mapped variants
+ * This endpoint streams functional impact statements for all current annotated variants
* associated with a specific score set. The response is delivered as newline-delimited
* JSON (NDJSON) format.
*
* NDJSON Response Format:
- * Each line corresponds to a mapped variant and contains a JSON object with the following
- * structure:
+ * Each line in the response corresponds to an annotated variant and contains a JSON
+ * object with the following structure:
* ```
* {
- * "variant_urn": "",
+ * "variant_urn": "",
* "annotation": {
* ... // Functional impact statement details
* }
@@ -958,7 +1050,7 @@ export interface paths {
* truncating the stream, and carries an additional `error` object:
* ```
* {
- * "variant_urn": "",
+ * "variant_urn": "",
* "annotation": null,
* "error": {"type": "", "detail": ""}
* }
@@ -974,36 +1066,38 @@ export interface paths {
*
* Returns:
* StreamingResponse: NDJSON stream containing functional impact statements for each
- * mapped variant. Response includes headers with total count, processing start time,
+ * annotated variant. Response includes headers with total count, processing start time,
* and stream type information.
*
* Raises:
* HTTPException:
* - 404 if the score set with the given URN is not found
- * - 404 if no mapped variants are associated with the score set
+ * - 404 if no annotated variants are associated with the score set
* - 403 if the user lacks READ permission for the score set
*
* Note:
- * Only current (non-historical) mapped variants are included in the response.
- * The function requires appropriate read permissions on the score set.
+ * The function requires appropriate read permissions on the score set. Use the `as_of`
+ * parameter to reconstruct the molecular layer as it stood at a specific instant, over
+ * the variant's fixed score. The response is streamed to allow for efficient handling of
+ * large datasets, and progress updates are logged for monitoring purposes.
*/
get: operations["get_score_set_annotated_variants_functional_statement_api_v1_score_sets__urn__annotated_variants_functional_statement_get"];
};
"/api/v1/score-sets/{urn}/annotated-variants/study-result": {
/**
- * Get functional study result annotations for mapped variants within a score set
+ * Get functional study result annotations for annotated variants within a score set
* @description Retrieve functional study results for annotated variants in a score set.
*
- * This endpoint streams functional study result annotations for all current mapped variants
+ * This endpoint streams functional study result annotations for all current annotated variants
* associated with a specific score set. The results are returned as newline-delimited JSON
* (NDJSON) format for efficient streaming of large datasets.
*
* NDJSON Response Format:
- * Each line corresponds to a mapped variant and contains a JSON object with the following
- * structure:
+ * Each line in the response corresponds to a annotated variant and contains a JSON
+ * object with the following structure:
* ```
* {
- * "variant_urn": "",
+ * "variant_urn": "",
* "annotation": {
* ... // Functional study result details
* }
@@ -1015,7 +1109,7 @@ export interface paths {
* truncating the stream, and carries an additional `error` object:
* ```
* {
- * "variant_urn": "",
+ * "variant_urn": "",
* "annotation": null,
* "error": {"type": "", "detail": ""}
* }
@@ -1032,7 +1126,7 @@ export interface paths {
* Returns:
* StreamingResponse: A streaming response containing functional study results in NDJSON format.
* Headers include:
- * - X-Total-Count: Total number of mapped variants being streamed
+ * - X-Total-Count: Total number of annotated variants being streamed
* - X-Processing-Started: ISO timestamp when processing began
* - X-Stream-Type: Set to "functional-study-result"
* - Access-Control-Expose-Headers: Exposed headers for CORS
@@ -1040,13 +1134,14 @@ export interface paths {
* Raises:
* HTTPException:
* - 404 if the score set with the given URN is not found
- * - 404 if no mapped variants are associated with the score set
+ * - 404 if no annotated variants are associated with the score set
* - 403 if the user lacks READ permission for the score set
*
* Notes:
- * - Only returns current mapped variants (MappedVariant.current == True)
- * - Eagerly loads related ScoreSet data including publications, users, license, and experiment
- * - Logs requests and errors for monitoring and debugging purposes
+ * - The `as_of` parameter allows reconstruction of the molecular layer as it stood at a specific
+ * instant, over the variant's fixed score. It is ISO 8601 formatted and ideally timezone-aware.
+ * - The response is streamed to allow for efficient handling of large datasets, and progress updates
+ * are logged for monitoring purposes.
*/
get: operations["get_score_set_annotated_variants_functional_study_result_api_v1_score_sets__urn__annotated_variants_study_result_get"];
};
@@ -1075,13 +1170,20 @@ export interface paths {
/**
* Get clinical control options for a score set
* @description Fetch clinical control options for a given score set.
+ *
+ * Each ``(db_name, db_version)`` pair returned here was live at the moment of this call, but
+ * liveness is re-evaluated independently per request. A pair fetched here can have its backing
+ * ``ClinvarAlleleLink`` retired before a later call to ``GET /score-sets/{urn}/clinical-controls``
+ * filters on it, in which case that call 404s. Pin an explicit ``as_of`` on both calls to avoid this
+ * possibility.
*/
get: operations["get_clinical_controls_options_for_score_set_api_v1_score_sets__urn__clinical_controls_options_get"];
};
"/api/v1/score-sets/{urn}/gnomad-variants": {
/**
* Get gnomad variants for a score set
- * @description Fetch relevant gnomad variants for a given score set.
+ * @description Fetch relevant gnomad variants for a given score set, each paired with the score-set variants (and
+ * annotated allele digests) it links to over the allele substrate.
*/
get: operations["get_gnomad_variants_for_score_set_api_v1_score_sets__urn__gnomad_variants_get"];
};
@@ -1410,20 +1512,51 @@ export interface paths {
*/
put: operations["update_user_api_v1_users___id__put"];
};
- "/api/v1/variants/clingen-allele-id-lookups": {
+ "/api/v1/variants/vrs/{identifier}": {
/**
- * Lookup variants by ClinGen Allele IDs
- * @description Lookup variants by ClinGen Allele IDs.
+ * Look up variants by VRS identifier
+ * @description Resolve a GA4GH VRS identifier to the readable variants whose mapping links that allele.
+ *
+ * A deduplicated allele may be shared across score sets, so one identifier can resolve to several
+ * variants. This is a lookup returning a collection: results are filtered to the score sets the caller
+ * may read, and an empty list is returned when nothing readable matches. An absent identifier and a
+ * match visible only in a private score set are deliberately indistinguishable (both yield ``[]``), so
+ * the response never reveals a private allele's existence.
*/
- post: operations["lookup_variants_api_v1_variants_clingen_allele_id_lookups_post"];
+ get: operations["lookup_variants_by_vrs_identifier_api_v1_variants_vrs__identifier__get"];
};
"/api/v1/variants/{urn}": {
/**
- * Fetch variant by URN
- * @description Fetch a single variant by URN.
+ * Fetch assayed variant detail by URN
+ * @description Fetch the two-tier detail envelope for a single assayed variant by URN.
+ *
+ * Flat assay-level fields for the common UI case plus the spec-pure GA4GH CategoricalVariant and a
+ * digest-keyed annotation map for machine/standard consumers. A superseded variant is served (it is
+ * the citable unit) but self-describes via isCurrent/supersededByScoreSet rather than reading as current.
*/
get: operations["get_variant_api_v1_variants__urn__get"];
};
+ "/api/v1/variants/{urn}/va/study-result": {
+ /**
+ * Construct a VA-Spec StudyResult for a variant
+ * @description Construct a single VA-Spec StudyResult for a variant by URN, from its mapping substrate.
+ */
+ get: operations["get_variant_study_result_api_v1_variants__urn__va_study_result_get"];
+ };
+ "/api/v1/variants/{urn}/va/functional-statement": {
+ /**
+ * Construct a VA-Spec functional-impact Statement for a variant
+ * @description Construct a single VA-Spec functional-impact Statement for a variant by URN.
+ */
+ get: operations["get_variant_functional_impact_statement_api_v1_variants__urn__va_functional_statement_get"];
+ };
+ "/api/v1/variants/{urn}/va/pathogenicity-statement": {
+ /**
+ * Construct a VA-Spec pathogenicity Statement for a variant
+ * @description Construct a single VA-Spec pathogenicity Statement for a variant by URN.
+ */
+ get: operations["get_variant_pathogenicity_statement_api_v1_variants__urn__va_pathogenicity_statement_get"];
+ };
"/api/v1/variants/{urn}/csv-namespaces": {
/**
* List the CSV column namespaces this variant has data for
@@ -1763,16 +1896,120 @@ export interface components {
state: components["schemas"]["LiteralSequenceExpression"] | components["schemas"]["ReferenceLengthExpression"] | components["schemas"]["LengthExpression"];
};
/**
- * AnnotationLayer
- * @description Annotation layer for a variant mapping result.
+ * AlleleAnnotations
+ * @description The external annotations for one allele, sparse — each source absent unless it has data.
+ */
+ AlleleAnnotations: {
+ vep?: components["schemas"]["VepAnnotation"] | null;
+ gnomad?: components["schemas"]["GnomadAnnotation"] | null;
+ /**
+ * Clinvar
+ * @default []
+ */
+ clinvar?: components["schemas"]["ClinvarAnnotation"][];
+ };
+ /**
+ * AlleleDerivation
+ * @description How an allele's representation was arrived at, *relative to the focus allele*: the
+ * confidence/provenance axis, and the one the UI badges on.
*
- * Mirrors the ``AnnotationLayer`` enum produced by the dcd-mapping QC API.
- * Values use full names so they round-trip readably through the database
- * column; the dcd-mapping payload uses short single-character codes
- * (``p`` / ``c`` / ``g``) which the worker translates via :func:`from_wire`.
+ * There is deliberately **no** ``authoritative`` value: the focus allele is marked by
+ * :attr:`AlleleIdentity.is_focus`, not by a derivation. That keeps the axis meaningful even when a
+ * variant was not explicitly measured. See the module docstring for why this axis is separate from
+ * the Cat-VRS ``relation``, and why neither may be inferred from the other.
* @enum {string}
*/
- AnnotationLayer: "protein" | "cdna" | "genomic";
+ AlleleDerivation: "projection" | "candidate" | "convergent";
+ /**
+ * AlleleDetail
+ * @description The allele-detail envelope (``GET /alleles/{digest|CAID}``).
+ *
+ * ``alleles`` is the full cross-layer equivalence class, keyed by VRS digest; ``isFocus`` marks
+ * the queried allele. ``annotations`` shares those same keys. Measurement-agnostic: no score,
+ * classification, or re-anchored Cat-VRS (those belong to ``GET /variants/{urn}``).
+ */
+ AlleleDetail: {
+ /** Digest */
+ digest: string;
+ /** Level */
+ level?: string | null;
+ /** Hgvs */
+ hgvs?: string | null;
+ /** Clingenalleleid */
+ clingenAlleleId?: string | null;
+ /** Vrs */
+ vrs?: Record | null;
+ /**
+ * Alleles
+ * @default {}
+ */
+ alleles?: {
+ [key: string]: components["schemas"]["AlleleIdentity"];
+ };
+ /**
+ * Annotations
+ * @default {}
+ */
+ annotations?: {
+ [key: string]: components["schemas"]["AlleleAnnotations"];
+ };
+ };
+ /**
+ * AlleleIdentity
+ * @description One allele in a view's ``alleles`` map, keyed by VRS digest and labelled relative to the
+ * view's focus allele. ``isFocus`` marks the anchor; ``relation`` and ``derivation`` describe
+ * every other member's relationship to it and are absent on the focus itself.
+ */
+ AlleleIdentity: {
+ /** Level */
+ level?: string | null;
+ /** Hgvs */
+ hgvs?: string | null;
+ /** Clingenalleleid */
+ clingenAlleleId?: string | null;
+ /** Isfocus */
+ isFocus: boolean;
+ /** Relation */
+ relation?: string | null;
+ derivation?: components["schemas"]["AlleleDerivation"] | null;
+ /** Projectionof */
+ projectionOf?: string | null;
+ };
+ /**
+ * AlleleMeasurement
+ * @description One measurement in the queried ClinGen allele's cross-layer equivalence class.
+ *
+ * ``assayLevel`` is the level at which this measurement was actually assayed (``protein`` / ``cdna`` /
+ * ``genomic``) — always shown, since the measured level is the clinically load-bearing fact.
+ * ``relationship`` says how the measurement relates to the queried ClinGen id: ``direct`` (assayed at
+ * this allele), ``protein_consequence`` (a protein measurement of a nt query's consequence), or
+ * ``nucleotide_encoding`` (a nt measurement encoding a protein query). ``preferredClassification`` is the
+ * readable functional classification the UI defaults to (primary-first cascade, RUO excluded), omitted
+ * when absent or gated. ``isCurrent`` /
+ * ``supersededByScoreSet`` let a superseded measurement (surfaced only under ``include_superseded``)
+ * self-describe; ``supersededByScoreSet`` is the superseding *score set*'s URN.
+ */
+ AlleleMeasurement: {
+ /** Varianturn */
+ variantUrn: string;
+ /** Score */
+ score?: number | null;
+ assayLevel?: components["schemas"]["SequenceLevel"] | null;
+ relationship: components["schemas"]["MeasurementRelationship"];
+ /** Assaylevelhgvs */
+ assayLevelHgvs?: string | null;
+ /** Submittedhgvs */
+ submittedHgvs?: string | null;
+ /** Scoreseturn */
+ scoreSetUrn: string;
+ /** Scoresettitle */
+ scoreSetTitle: string;
+ preferredClassification?: components["schemas"]["SavedFunctionalClassification"] | null;
+ /** Iscurrent */
+ isCurrent: boolean;
+ /** Supersededbyscoreset */
+ supersededByScoreSet?: string | null;
+ };
/** ApiVersion */
ApiVersion: {
/** Name */
@@ -1979,33 +2216,6 @@ export interface components {
/** @description An optional Sequence Reference on which all of the in-cis Alleles are found. When defined, this may be used to implicitly define the `sequenceReference` attribute for each of the CisPhasedBlock member Alleles. */
sequenceReference?: components["schemas"]["SequenceReference"] | null;
};
- /**
- * ClingenAlleleIdVariantLookupResponse
- * @description Response model for a variant lookup by ClinGen allele ID
- */
- ClingenAlleleIdVariantLookupResponse: {
- /** Clingenalleleid */
- clingenAlleleId: string;
- exactMatch?: components["schemas"]["Variant"] | null;
- /**
- * Equivalentnt
- * @default []
- */
- equivalentNt?: components["schemas"]["Variant"][];
- /**
- * Equivalentaa
- * @default []
- */
- equivalentAa?: components["schemas"]["Variant"][];
- };
- /**
- * ClingenAlleleIdVariantLookupsRequest
- * @description A request to search for variants matching a list of ClinGen allele IDs
- */
- ClingenAlleleIdVariantLookupsRequest: {
- /** Clingenalleleids */
- clingenAlleleIds: string[];
- };
/** ClinicalControlOptions */
ClinicalControlOptions: {
/** Dbname */
@@ -2013,8 +2223,8 @@ export interface components {
/** Availableversions */
availableVersions: string[];
};
- /** ClinicalControlWithMappedVariants */
- ClinicalControlWithMappedVariants: {
+ /** ClinicalControlWithClinvarLinks */
+ ClinicalControlWithClinvarLinks: {
/** Dbidentifier */
dbIdentifier: string;
/** Genesymbol */
@@ -2041,8 +2251,34 @@ export interface components {
creationDate: string;
/** Recordtype */
recordType?: string;
- /** Mappedvariants */
- mappedVariants: components["schemas"]["MappedVariantForClinicalControl"][];
+ /** Clinvarlinks */
+ clinvarLinks: components["schemas"]["ClinvarVariantLink"][];
+ };
+ /**
+ * ClinvarAnnotation
+ * @description One ClinVar assertion for an allele (an allele may carry one per release).
+ */
+ ClinvarAnnotation: {
+ /** Clinicalsignificance */
+ clinicalSignificance: string;
+ /** Clinicalreviewstatus */
+ clinicalReviewStatus: string;
+ /** Clinvarvariationid */
+ clinvarVariationId?: string | null;
+ /** Clinvaralleleid */
+ clinvarAlleleId: string;
+ /** Dbversion */
+ dbVersion: string;
+ };
+ /**
+ * ClinvarVariantLink
+ * @description One score-set variant a ClinVar control reaches, tagged with the annotated allele's digest.
+ */
+ ClinvarVariantLink: {
+ /** Varianturn */
+ variantUrn: string;
+ /** Alleledigest */
+ alleleDigest?: string | null;
};
/**
* Coding
@@ -3527,10 +3763,10 @@ export interface components {
totalScoredVariants: number;
};
/**
- * GnomADVariantWithMappedVariants
- * @description GnomAD variant view model with mapped variants for non-admin clients.
+ * GnomADVariantWithVariantLinks
+ * @description GnomAD variant + its score-set variant links, for non-admin clients.
*/
- GnomADVariantWithMappedVariants: {
+ GnomADVariantWithVariantLinks: {
/** Dbname */
dbName: string;
/** Dbidentifier */
@@ -3561,8 +3797,39 @@ export interface components {
* Format: date
*/
modificationDate: string;
- /** Mappedvariants */
- mappedVariants: components["schemas"]["MappedVariant"][];
+ /** Variantlinks */
+ variantLinks: components["schemas"]["GnomadVariantLink"][];
+ };
+ /**
+ * GnomadAnnotation
+ * @description gnomAD population frequency for an allele.
+ */
+ GnomadAnnotation: {
+ /** Allelefrequency */
+ alleleFrequency: number;
+ /** Allelecount */
+ alleleCount: number;
+ /** Allelenumber */
+ alleleNumber: number;
+ /** Faf95Max */
+ faf95Max?: number | null;
+ /** Dbversion */
+ dbVersion: string;
+ /** Dbidentifier */
+ dbIdentifier: string;
+ };
+ /**
+ * GnomadVariantLink
+ * @description One score-set variant a gnomAD frequency record reaches, tagged with the annotated allele's digest.
+ *
+ * Mirrors :class:`clinical_control.ClinvarVariantLink`: a gnomAD variant fans out to every allele that
+ * resolved to it, and each allele belongs to a score-set variant.
+ */
+ GnomadVariantLink: {
+ /** Varianturn */
+ variantUrn: string;
+ /** Alleledigest */
+ alleleDigest?: string | null;
};
/**
* GroupBy
@@ -3574,6 +3841,28 @@ export interface components {
/** Detail */
detail?: components["schemas"]["ValidationError"][];
};
+ /**
+ * HgvsField
+ * @description An HGVS expression with its parsed substitution block riding alongside when representable.
+ *
+ * ``hgvs`` is always present; ``position``/``ref``/``alt`` appear only for a placeable simple
+ * substitution (the heatmap grid) and are omitted for splice/indels/multivariants.
+ */
+ HgvsField: {
+ /** Hgvs */
+ hgvs: string;
+ /** Position */
+ position?: number | null;
+ /** Ref */
+ ref?: string | null;
+ /** Alt */
+ alt?: string | null;
+ };
+ /** HgvsValidationRequest */
+ HgvsValidationRequest: {
+ /** Variant */
+ variant: string;
+ };
/**
* JobRunDetail
* @description Single-job-run detail response including the error traceback.
@@ -3701,6 +3990,35 @@ export interface components {
/** Description */
description?: string | null;
};
+ /**
+ * LeanVariant
+ * @description One pre-chewed per-variant record feeding the score-set table, heatmap, and histograms.
+ *
+ * ``variantUrn`` is the universal selection key; ``assayLevelDigest`` bridges into the digest-keyed
+ * annotation dimensions. The submitted HGVS (``hgvsNt``/``hgvsPro``/``hgvsSplice``, target frame) carry
+ * the depositor's frame for the heatmap's raw↔mapped toggle. The mapped (reference) frame is the
+ * ``mapped`` :class:`MappedTriple` plus the ``assayLevel`` pointer (an ``SequenceLevel`` value) naming
+ * the measured/canonical slot: ``mapped[assayLevel]`` is the measured representation and ``mapped.cdna``
+ * the level-invariant search key. Fields are omitted when null.
+ */
+ LeanVariant: {
+ /** Varianturn */
+ variantUrn: string;
+ /** Score */
+ score?: number | null;
+ /** Consequence */
+ consequence?: string | null;
+ /** Clingenalleleid */
+ clingenAlleleId?: string | null;
+ /** Assayleveldigest */
+ assayLevelDigest?: string | null;
+ hgvsNt?: components["schemas"]["HgvsField"] | null;
+ hgvsPro?: components["schemas"]["HgvsField"] | null;
+ hgvsSplice?: components["schemas"]["HgvsField"] | null;
+ assayLevel?: components["schemas"]["SequenceLevel"] | null;
+ /** @default {} */
+ mapped?: components["schemas"]["MappedTriple"];
+ };
/**
* LengthExpression
* @description A sequence expressed only by its length.
@@ -3849,96 +4167,31 @@ export interface components {
*/
mappings?: components["schemas"]["ConceptMapping"][] | null;
};
- /** MappedVariant */
- MappedVariant: {
- /** Premapped */
- preMapped?: unknown;
- /** Postmapped */
- postMapped?: unknown;
- /** Vrsversion */
- vrsVersion?: string | null;
- /** Errormessage */
- errorMessage?: string | null;
- /**
- * Modificationdate
- * Format: date
- */
- modificationDate: string;
- /**
- * Mappeddate
- * Format: date
- */
- mappedDate: string;
- /** Mappingapiversion */
- mappingApiVersion: string;
- /** Current */
- current: boolean;
- alignmentLevel?: components["schemas"]["AnnotationLayer"] | null;
- /** Atmismatchedlocus */
- atMismatchedLocus?: boolean | null;
- /** Neargap */
- nearGap?: boolean | null;
- /** Varianturn */
- variantUrn: string;
- /** Id */
- id: number;
- /** Clingenalleleid */
- clingenAlleleId?: string | null;
- /** Recordtype */
- recordType?: string;
- };
- /** MappedVariantForClinicalControl */
- MappedVariantForClinicalControl: {
- /** Varianturn */
- variantUrn: string;
- };
/**
- * MappedVariantWithMappingDetails
- * @description Client-facing variant of :class:`SavedMappedVariantWithMappingDetails`.
+ * MappedTriple
+ * @description The mapped (reference-frame) HGVS keyed by level — the canonical projection of the measured change.
+ *
+ * One slot per level. A nucleotide assay populates all three (``mapped[assayLevel]`` is the measured
+ * slot; ``cdna`` is the level-invariant search key, present even when ``assayLevel`` is ``genomic``); a
+ * protein assay populates only ``protein`` (the ambiguous c/g fan-out is not fabricated). Null slots are
+ * omitted under ``response_model_exclude_none``.
*/
- MappedVariantWithMappingDetails: {
- /** Premapped */
- preMapped?: unknown;
- /** Postmapped */
- postMapped?: unknown;
- /** Vrsversion */
- vrsVersion?: string | null;
- /** Errormessage */
- errorMessage?: string | null;
- /**
- * Modificationdate
- * Format: date
- */
- modificationDate: string;
- /**
- * Mappeddate
- * Format: date
- */
- mappedDate: string;
- /** Mappingapiversion */
- mappingApiVersion: string;
- /** Current */
- current: boolean;
- alignmentLevel?: components["schemas"]["AnnotationLayer"] | null;
- /** Atmismatchedlocus */
- atMismatchedLocus?: boolean | null;
- /** Neargap */
- nearGap?: boolean | null;
- /** Varianturn */
- variantUrn: string;
- /** Id */
- id: number;
- /** Clingenalleleid */
- clingenAlleleId?: string | null;
- /** Recordtype */
- recordType?: string;
- targetGeneMapping?: components["schemas"]["TargetGeneMapping"] | null;
+ MappedTriple: {
+ genomic?: components["schemas"]["HgvsField"] | null;
+ cdna?: components["schemas"]["HgvsField"] | null;
+ protein?: components["schemas"]["HgvsField"] | null;
};
/**
* MappingState
* @enum {string}
*/
MappingState: "incomplete" | "processing" | "failed" | "complete" | "pending_variant_processing" | "not_attempted" | "queued";
+ /**
+ * MeasurementRelationship
+ * @description How a measurement relates to the queried ClinGen id, by the measurement's *measured* level.
+ * @enum {string}
+ */
+ MeasurementRelationship: "direct" | "protein_consequence" | "nucleotide_encoding";
/**
* MembershipOperator
* @description The logical relationship between members of the set, that indicates how they
@@ -5225,6 +5478,20 @@ export interface components {
*/
seqrepo_dependency_version: string;
};
+ /**
+ * SequenceLevel
+ * @description The molecular sequence level of a variant representation: genomic DNA, coding DNA, or protein.
+ *
+ * A single, duty-neutral closed set reused across several columns that each carry a different
+ * semantic meaning: the level a variant was *assayed* at (``assay_level``), the level dcd-mapping
+ * *aligned* it at (``alignment_level``), and the level of a stored allele (``level``).
+ *
+ * Values use full names so they round-trip readably through the database column; the dcd-mapping
+ * payload uses short single-character codes (``p`` / ``c`` / ``g``) which the worker translates via
+ * :func:`from_wire`.
+ * @enum {string}
+ */
+ SequenceLevel: "protein" | "cdna" | "genomic";
/**
* SequenceLocation
* @description A `Location` defined by an interval on a `Sequence`.
@@ -5701,69 +5968,6 @@ export interface components {
targetSequence?: components["schemas"]["TargetSequenceCreate"] | null;
targetAccession?: components["schemas"]["TargetAccessionCreate"] | null;
};
- /** TargetGeneMapping */
- TargetGeneMapping: {
- alignmentLevel: components["schemas"]["AnnotationLayer"];
- /**
- * Preferred
- * @default false
- */
- preferred?: boolean;
- /** Referenceassembly */
- referenceAssembly?: string | null;
- /** Referenceaccession */
- referenceAccession?: string | null;
- /** Referencesequenceid */
- referenceSequenceId?: string | null;
- /** Alignmentscore */
- alignmentScore?: number | null;
- /** Nextbestalignmentscore */
- nextBestAlignmentScore?: number | null;
- /** Alignmentlength */
- alignmentLength?: number | null;
- /** Alignmentstring */
- alignmentString?: string | null;
- /** Mismatchcount */
- mismatchCount?: number | null;
- /** Gapcount */
- gapCount?: number | null;
- /** Percentidentity */
- percentIdentity?: number | null;
- /** Totalvariants */
- totalVariants?: number | null;
- /** Variantsfailed */
- variantsFailed?: number | null;
- /** Variantswithalignmentwarnings */
- variantsWithAlignmentWarnings?: number | null;
- /** Variantsmappedcleanly */
- variantsMappedCleanly?: number | null;
- /** Toolname */
- toolName: string;
- /** Toolversion */
- toolVersion: string;
- /** Toolparameters */
- toolParameters?: Record | null;
- /** Alignmentmetadata */
- alignmentMetadata?: Record | null;
- /** Vrsversion */
- vrsVersion?: string | null;
- /** Mappeddate */
- mappedDate?: string | null;
- /** Id */
- id: number;
- /**
- * Creationdate
- * Format: date
- */
- creationDate: string;
- /**
- * Modificationdate
- * Format: date
- */
- modificationDate: string;
- /** Recordtype */
- recordType?: string;
- };
/**
* TargetGeneWithScoreSetUrn
* @description Target gene view model containing its score set urn.
@@ -6014,14 +6218,89 @@ export interface components {
type: string;
};
/**
- * Variant
- * @description View model for a variant, defined by its ClinGen allele id, with associated variant effect measurements
+ * VariantClassification
+ * @description A functional classification the variant falls into, tagged with its calibration context.
+ *
+ * A score set may carry several calibrations, so a variant has one classification per calibration;
+ * ``primary`` flags the UI default. The classifications are calibration-derived but as-of-invariant
+ * (calibrations carry no valid-time), so they are always the current calibration state.
+ */
+ VariantClassification: {
+ /** Calibrationid */
+ calibrationId: number;
+ /** Primary */
+ primary: boolean;
+ classification: components["schemas"]["SavedFunctionalClassification"];
+ };
+ /**
+ * VariantDetail
+ * @description The assayed variant-detail envelope (``GET /variants/{urn}``).
+ *
+ * Two tiers: flat, UI-ergonomic assay fields (the ``targetHgvs``/``referenceHgvs`` coordinate pair
+ * is a client-side toggle, no refetch; the ``preMapped``/``postMapped`` raw VRS pair lets a
+ * VRS/bulk consumer read the assayed-level and measured VRS directly) plus the spec-pure GA4GH
+ * ``molecularRepresentation`` (``CategoricalVariant``, no MaveDB fields inside). The MaveDB layer
+ * rides alongside, keyed by VRS digest: the ``alleles`` identity sidecar (per-allele ``level`` /
+ * ``hgvs`` / ``clingenAlleleId`` / ``relation`` — one entry per linked allele, sharing keys with
+ * ``annotations``) and the ``annotations`` map. ``isCurrent``/``supersededByScoreSet`` let a
+ * superseded variant self-describe: ``supersededByScoreSet`` is the superseding *score set*'s URN,
+ * not a variant URN. Supersession is versioned at the score-set level, and a newer version may add,
+ * drop, or renumber variants — so there is no stable superseding-*variant* pointer to hand back; a
+ * consumer resolves the current measurement by looking this variant up within that score set.
+ *
+ * Unlike most MaveDB response models, this one serializes with ``exclude_none=False`` on both routes
+ * that emit it (``GET /variants/{urn}`` and the bulk ``GET /score-sets/{urn}/variant-details`` NDJSON
+ * stream): the shape is a stable, self-describing envelope, so every record carries the same key set
+ * and an unmapped variant reads ``preMapped``/``postMapped``/``molecularRepresentation`` as ``null``
+ * rather than dropping them. The two routes are kept in lockstep — the same object, one shape.
*/
- Variant: {
+ VariantDetail: {
+ /** Urn */
+ urn: string;
+ /** Scores */
+ scores?: Record | null;
+ /** Counts */
+ counts?: Record