diff --git a/docs/content/finding-data/external-integrations.md b/docs/content/finding-data/external-integrations.md index e6005c88..4c914681 100644 --- a/docs/content/finding-data/external-integrations.md +++ b/docs/content/finding-data/external-integrations.md @@ -35,6 +35,8 @@ MaveDB displays ClinVar significance classifications and **star** status alongsi Mapped variants in MaveDB are cross-referenced with the [gnomAD database](https://gnomad.broadinstitute.org/) to retrieve population allele frequency data. This integration provides context about the prevalence of variants in diverse human populations, which is an important factor in clinical variant interpretation alongside functional evidence. +A variant's frequency is displayed on its [variant page](../mavemd/variant-page.md), and is available in bulk through the `gnomad` namespace of the variant data download. Each frequency is matched by ClinGen allele ID, so it is a direct assertion about that variant rather than an aggregate over related variants. Variants absent from gnomAD show no frequency. + ## Ensembl VEP MaveDB uses the [Ensembl Variant Effect Predictor (VEP)](https://www.ensembl.org/info/docs/tools/vep/index.html) to annotate mapped variants with predicted functional consequences, including effects on protein coding sequences, splicing, and regulatory regions. These VEP annotations are displayed alongside variant effect scores on score set pages, providing additional context for interpreting the functional impact of each variant. diff --git a/docs/content/mavemd/variant-page.md b/docs/content/mavemd/variant-page.md index 2a1b20ce..5d52f1f1 100644 --- a/docs/content/mavemd/variant-page.md +++ b/docs/content/mavemd/variant-page.md @@ -36,6 +36,17 @@ Each dataset on the variant page includes an [assay fact](../reference/assay-fac For the full list of assay fact properties and their definitions, see the [assay facts reference](../reference/assay-facts.md). +## Annotations + +Beneath the assay details, an annotations card gathers evidence about the variant itself, independent of any one assay. **Classification** reports the selected measurement's functional score, ACMG code, and OddsPath ratio. + +**Population frequency** reports the variant's frequency in [gnomAD](../finding-data/external-integrations.md#gnomad), where it is present: + +- **AF** -- The allele frequency, followed by the allele count and allele number it was computed from. +- **FAF95** -- The filtering allele frequency at 95% confidence, a conservative sampling-adjusted estimate, with the genetic ancestry group that attains it. A variant whose FAF95 exceeds a disease's maximum credible allele frequency is too common to be pathogenic (ACMG BA1/BS1). + +The gnomAD release the frequencies were drawn from is shown alongside them, with a link to the variant's gnomAD page. Variants absent from gnomAD are reported as having no record rather than as having zero frequency. + ## Interactive histogram The variant page includes the same interactive score histogram shown on score set pages, but with the selected variant's position highlighted within the distribution. This visualization helps you see where the variant falls relative to all other measured variants in the assay. diff --git a/package-lock.json b/package-lock.json index d3fc69d7..ba5ad802 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mavedb-ui", - "version": "2026.2.4", + "version": "2026.2.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mavedb-ui", - "version": "2026.2.4", + "version": "2026.2.4.1", "dependencies": { "@fontsource/exo-2": "^5.2.8", "@fontsource/raleway": "^5.0.16", @@ -620,7 +620,6 @@ "hasInstallScript": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", @@ -662,7 +661,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -681,7 +679,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -702,7 +699,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -723,7 +719,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -744,7 +739,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -765,7 +759,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -786,7 +779,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -807,7 +799,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -828,7 +819,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -849,7 +839,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -870,7 +859,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -891,7 +879,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -912,7 +899,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10.0.0" }, @@ -927,7 +913,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=12" }, @@ -2075,9 +2060,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -2947,9 +2932,9 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -3003,9 +2988,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -3350,7 +3335,6 @@ "version": "4.0.3", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -4354,9 +4338,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -5545,9 +5529,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -5895,7 +5879,9 @@ } }, "node_modules/immutable": { - "version": "5.1.5", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", "devOptional": true, "license": "MIT" }, @@ -6522,9 +6508,9 @@ "peer": true }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -7718,9 +7704,9 @@ } }, "node_modules/molstar/node_modules/immutable": { - "version": "4.3.8", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.8.tgz", - "integrity": "sha512-d/Ld9aLbKpNwyl0KiM2CT1WYvkitQ1TSvmRtkcV8FKStiDoA7Slzgjmb/1G2yhKM1p0XeNOieaTbFZmU1d3Xuw==", + "version": "4.3.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.9.tgz", + "integrity": "sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==", "license": "MIT" }, "node_modules/moment": { @@ -7740,9 +7726,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -7778,8 +7764,7 @@ "node_modules/node-addon-api": { "version": "7.1.1", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/node-fetch": { "version": "2.7.0", @@ -8274,9 +8259,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "funding": [ { "type": "opencollective", @@ -8293,7 +8278,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8606,7 +8591,6 @@ "version": "4.1.2", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 14.18.0" }, @@ -8952,7 +8936,6 @@ "version": "1.97.3", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -9008,6 +8991,86 @@ "sass-embedded-win32-x64": "1.97.3" } }, + "node_modules/sass-embedded-all-unknown": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-all-unknown/-/sass-embedded-all-unknown-1.97.3.tgz", + "integrity": "sha512-t6N46NlPuXiY3rlmG6/+1nwebOBOaLFOOVqNQOC2cJhghOD4hh2kHNQQTorCsbY9S1Kir2la1/XLBwOJfui0xg==", + "cpu": [ + "!arm", + "!arm64", + "!riscv64", + "!x64" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "sass": "1.97.3" + } + }, + "node_modules/sass-embedded-android-arm": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm/-/sass-embedded-android-arm-1.97.3.tgz", + "integrity": "sha512-cRTtf/KV/q0nzGZoUzVkeIVVFv3L/tS1w4WnlHapphsjTXF/duTxI8JOU1c/9GhRPiMdfeXH7vYNcMmtjwX7jg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-arm64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.97.3.tgz", + "integrity": "sha512-aiZ6iqiHsUsaDx0EFbbmmA0QgxicSxVVN3lnJJ0f1RStY0DthUkquGT5RJ4TPdaZ6ebeJWkboV4bra+CP766eA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-riscv64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.97.3.tgz", + "integrity": "sha512-zVEDgl9JJodofGHobaM/q6pNETG69uuBIGQHRo789jloESxxZe82lI3AWJQuPmYCOG5ElfRthqgv89h3gTeLYA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-android-x64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-android-x64/-/sass-embedded-android-x64-1.97.3.tgz", + "integrity": "sha512-3ke0le7ZKepyXn/dKKspYkpBC0zUk/BMciyP5ajQUDy4qJwobd8zXdAq6kOkdiMB+d9UFJOmEkvgFJHl3lqwcw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/sass-embedded-darwin-arm64": { "version": "1.97.3", "cpu": [ @@ -9022,6 +9085,198 @@ "node": ">=14.0.0" } }, + "node_modules/sass-embedded-darwin-x64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.97.3.tgz", + "integrity": "sha512-b/2RBs/2bZpP8lMkyZ0Px0vkVkT8uBd0YXpOwK7iOwYkAT8SsO4+WdVwErsqC65vI5e1e5p1bb20tuwsoQBMVA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-arm": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.97.3.tgz", + "integrity": "sha512-2lPQ7HQQg4CKsH18FTsj2hbw5GJa6sBQgDsls+cV7buXlHjqF8iTKhAQViT6nrpLK/e8nFCoaRgSqEC8xMnXuA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-arm64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.97.3.tgz", + "integrity": "sha512-IP1+2otCT3DuV46ooxPaOKV1oL5rLjteRzf8ldZtfIEcwhSgSsHgA71CbjYgLEwMY9h4jeal8Jfv3QnedPvSjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.97.3.tgz", + "integrity": "sha512-cBTMU68X2opBpoYsSZnI321gnoaiMBEtc+60CKCclN6PCL3W3uXm8g4TLoil1hDD6mqU9YYNlVG6sJ+ZNef6Lg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.97.3.tgz", + "integrity": "sha512-Lij0SdZCsr+mNRSyDZ7XtJpXEITrYsaGbOTz5e6uFLJ9bmzUbV7M8BXz2/cA7bhfpRPT7/lwRKPdV4+aR9Ozcw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-riscv64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.97.3.tgz", + "integrity": "sha512-sBeLFIzMGshR4WmHAD4oIM7WJVkSoCIEwutzptFtGlSlwfNiijULp+J5hA2KteGvI6Gji35apR5aWj66wEn/iA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-x64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.97.3.tgz", + "integrity": "sha512-/oWJ+OVrDg7ADDQxRLC/4g1+Nsz1g4mkYS2t6XmyMJKFTFK50FVI2t5sOdFH+zmMp+nXHKM036W94y9m4jjEcw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-riscv64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.97.3.tgz", + "integrity": "sha512-l3IfySApLVYdNx0Kjm7Zehte1CDPZVcldma3dZt+TfzvlAEerM6YDgsk5XEj3L8eHBCgHgF4A0MJspHEo2WNfA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-x64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.97.3.tgz", + "integrity": "sha512-Kwqwc/jSSlcpRjULAOVbndqEy2GBzo6OBmmuBVINWUaJLJ8Kczz3vIsDUWLfWz/kTEw9FHBSiL0WCtYLVAXSLg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-unknown-all": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-unknown-all/-/sass-embedded-unknown-all-1.97.3.tgz", + "integrity": "sha512-/GHajyYJmvb0IABUQHbVHf1nuHPtIDo/ClMZ81IDr59wT5CNcMe7/dMNujXwWugtQVGI5UGmqXWZQCeoGnct8Q==", + "license": "MIT", + "optional": true, + "os": [ + "!android", + "!darwin", + "!linux", + "!win32" + ], + "dependencies": { + "sass": "1.97.3" + } + }, + "node_modules/sass-embedded-win32-arm64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.97.3.tgz", + "integrity": "sha512-RDGtRS1GVvQfMGAmVXNxYiUOvPzn9oO1zYB/XUM9fudDRnieYTcUytpNTQZLs6Y1KfJxgt5Y+giRceC92fT8Uw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-win32-x64": { + "version": "1.97.3", + "resolved": "https://registry.npmjs.org/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.97.3.tgz", + "integrity": "sha512-SFRa2lED9UEwV6vIGeBXeBOLKF+rowF3WmNfb/BzhxmdAsKofCXrJ8ePW7OcDVrvNEbTOGwhsReIsF5sH8fVaw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/sass-embedded/node_modules/supports-color": { "version": "8.1.1", "devOptional": true, @@ -9936,16 +10191,16 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/test-exclude/node_modules/minimatch": { diff --git a/package.json b/package.json index 1a04a075..85df4518 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mavedb-ui", - "version": "2026.2.4", + "version": "2026.2.4.1", "private": true, "type": "module", "scripts": { diff --git a/src/api/clingen/alleles.test.ts b/src/api/clingen/alleles.test.ts new file mode 100644 index 00000000..c28c8b4e --- /dev/null +++ b/src/api/clingen/alleles.test.ts @@ -0,0 +1,94 @@ +import axios from 'axios' +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import {getAlleleByGnomad} from './alleles' + +vi.mock('axios', () => ({ + default: { + get: vi.fn() + }, + isAxiosError: (error: unknown) => Boolean((error as {isAxiosError?: boolean})?.isAxiosError) +})) + +const mockedAxiosGet = vi.mocked(axios.get) + +/** A ClinGen allele, trimmed to the fields these tests care about. */ +const ALLELE = {'@id': 'http://reg.genome.network/allele/CA412761507'} + +/** The registry rejects a position whose reference allele doesn't match the assembly with a 400. */ +function referenceMismatch() { + return { + isAxiosError: true, + response: {status: 400, data: {errorType: 'IncorrectReferenceAllele'}} + } +} + +/** The HGVS strings axios was asked to resolve, in order. */ +function queriedHgvs() { + return mockedAxiosGet.mock.calls.map((call) => call[1]?.params?.hgvs) +} + +describe('getAlleleByGnomad', () => { + beforeEach(() => { + mockedAxiosGet.mockReset() + }) + + it('resolves a GRCh38 ID without trying GRCh37', async () => { + mockedAxiosGet.mockResolvedValueOnce({data: ALLELE} as never) + + expect(await getAlleleByGnomad('X-41334274-A-C')).toEqual({allele: ALLELE, assembly: 'grch38'}) + expect(queriedHgvs()).toEqual(['NC_000023.11:g.41334274A>C']) + }) + + it('falls back to the GRCh37 accession when the reference allele does not match GRCh38', async () => { + mockedAxiosGet.mockRejectedValueOnce(referenceMismatch()).mockResolvedValueOnce({data: ALLELE} as never) + + expect(await getAlleleByGnomad('17-7579472-G-C')).toEqual({allele: ALLELE, assembly: 'grch37'}) + expect(queriedHgvs()).toEqual(['NC_000017.11:g.7579472G>C', 'NC_000017.10:g.7579472G>C']) + }) + + it('propagates a server error instead of masking it as a wrong-assembly retry', async () => { + const outage = {isAxiosError: true, response: {status: 503, data: {}}} + mockedAxiosGet.mockRejectedValueOnce(outage) + + await expect(getAlleleByGnomad('X-41334274-A-C')).rejects.toBe(outage) + expect(mockedAxiosGet).toHaveBeenCalledTimes(1) + }) + + it('propagates a network failure, which carries no response', async () => { + const offline = {isAxiosError: true, message: 'Network Error'} + mockedAxiosGet.mockRejectedValueOnce(offline) + + await expect(getAlleleByGnomad('X-41334274-A-C')).rejects.toBe(offline) + expect(mockedAxiosGet).toHaveBeenCalledTimes(1) + }) + + it('surfaces the GRCh37 failure when neither assembly resolves', async () => { + const mismatch = referenceMismatch() + mockedAxiosGet.mockRejectedValueOnce(referenceMismatch()).mockRejectedValueOnce(mismatch) + + await expect(getAlleleByGnomad('17-7579472-G-C')).rejects.toBe(mismatch) + expect(mockedAxiosGet).toHaveBeenCalledTimes(2) + }) + + it('reads only the requested assembly when one is forced, without falling back', async () => { + mockedAxiosGet.mockResolvedValueOnce({data: ALLELE} as never) + + expect(await getAlleleByGnomad('17-7676154-G-C', 'grch37')).toEqual({allele: ALLELE, assembly: 'grch37'}) + expect(queriedHgvs()).toEqual(['NC_000017.10:g.7676154G>C']) + }) + + it('surfaces the failure of a forced assembly rather than trying the other one', async () => { + const mismatch = referenceMismatch() + mockedAxiosGet.mockRejectedValueOnce(mismatch) + + await expect(getAlleleByGnomad('17-7676154-G-C', 'grch37')).rejects.toBe(mismatch) + expect(mockedAxiosGet).toHaveBeenCalledTimes(1) + }) + + it('rejects an ID it cannot translate without calling the registry', async () => { + // Passes gnomadIdRegex, but the alleles describe no change. + await expect(getAlleleByGnomad('1-11796321-A-A')).rejects.toThrow('Not a valid gnomAD variant ID') + expect(mockedAxiosGet).not.toHaveBeenCalled() + }) +}) diff --git a/src/api/clingen/alleles.ts b/src/api/clingen/alleles.ts index b67f4a68..b17d00dc 100644 --- a/src/api/clingen/alleles.ts +++ b/src/api/clingen/alleles.ts @@ -1,4 +1,5 @@ -import axios from 'axios' +import axios, {isAxiosError} from 'axios' +import {type GenomeAssembly, gnomadIdToHgvsCandidates} from '@/lib/gnomad' import type {ClinGenAllele, ClinGenGene} from './types' const CLINGEN_BASE_URL = 'https://reg.genome.network' @@ -15,14 +16,14 @@ export async function getAlleleByHgvs(hgvs: string): Promise { return response.data } -export async function getAlleleByDbSnp(rsId: string): Promise { +export async function getAlleleByDbSnp(rsId: string): Promise { const response = await axios.get(`${CLINGEN_BASE_URL}/alleles`, { params: {'dbSNP.rs': rsId} }) return response.data } -export async function getAlleleByClinVar(variationId: string): Promise { +export async function getAlleleByClinVar(variationId: string): Promise { const response = await axios.get(`${CLINGEN_BASE_URL}/alleles`, { params: {'ClinVar.variationId': variationId} }) @@ -35,3 +36,48 @@ export async function getGeneBySymbol(symbol: string): Promise { }) return response.data } + +/** An allele resolved from a gnomAD ID, with the assembly whose coordinates it was read under. */ +export interface GnomadAlleleResult { + allele: ClinGenAllele + assembly: GenomeAssembly +} + +/** + * Look up an allele by gnomAD variant ID (e.g. 1-11796321-G-A). + * + * The ID is translated into genomic HGVS and resolved as such, rather than looked up among the registry's gnomAD + * cross-references: the registry computes an allele from any coordinates that match the reference, whereas its gnomAD + * index only covers variants it has ingested a gnomAD record for. + * + * A gnomAD ID doesn't name the reference genome its coordinates belong to, so by default GRCh38 is tried first and + * GRCh37 second. A 4xx from the first attempt is how coordinates announce they belong to the older assembly — the + * registry rejects a position whose reference allele doesn't match with `IncorrectReferenceAllele`. Note that a + * position can be valid under both assemblies while naming a different variant in each, in which case the earlier + * assembly wins; pass `assembly` to read the ID under a specific one instead, and let any failure propagate. + * + * @returns The allele, and the assembly its coordinates were read under. + */ +export async function getAlleleByGnomad(gnomadId: string, assembly?: GenomeAssembly): Promise { + const candidates = gnomadIdToHgvsCandidates(gnomadId).filter( + (candidate) => assembly == undefined || candidate.assembly === assembly + ) + if (candidates.length === 0) { + throw new Error(`Not a valid gnomAD variant ID: ${gnomadId}`) + } + + for (const [index, candidate] of candidates.entries()) { + const isLastCandidate = index === candidates.length - 1 + try { + return {allele: await getAlleleByHgvs(candidate.hgvs), assembly: candidate.assembly} + } catch (error) { + // Only a rejection of these coordinates warrants trying the next assembly; a network failure or registry outage + // should surface, as should the final attempt's failure. + if (isLastCandidate || !isAxiosError(error) || !error.response || error.response.status >= 500) { + throw error + } + } + } + // Unreachable: the loop either returns or throws on its final iteration. + throw new Error(`Could not resolve gnomAD variant ID: ${gnomadId}`) +} diff --git a/src/api/mavedb/index.ts b/src/api/mavedb/index.ts index c097c748..3a92e5cf 100644 --- a/src/api/mavedb/index.ts +++ b/src/api/mavedb/index.ts @@ -1,18 +1,3 @@ -import {isAxiosError} from 'axios' - -export interface ErrorResponse { - status: number - data?: Record -} - -/** Extract a normalized response from a caught Axios error. */ -export function getErrorResponse(e: unknown): ErrorResponse { - if (isAxiosError(e) && e.response) { - return {status: e.response.status, data: e.response.data as Record} - } - return {status: 500} -} - export * from './access-keys' export * from './calibrations' export * from './collections' diff --git a/src/api/mavedb/score-sets.ts b/src/api/mavedb/score-sets.ts index f2211a84..77cfe891 100644 --- a/src/api/mavedb/score-sets.ts +++ b/src/api/mavedb/score-sets.ts @@ -7,13 +7,17 @@ type ScoreSetSearch = components['schemas']['ScoreSetsSearch'] type ScoreSetsSearchResponse = components['schemas']['ScoreSetsSearchResponse'] export type ScoreSetsSearchFilterOptionsResponse = components['schemas']['ScoreSetsSearchFilterOptionsResponse'] -const HISTOGRAM_VARIANT_DATA_NAMESPACES = ['vep', 'scores', 'clingen'] +// Both screens read a score set's whole variant table from the same endpoint, but they render different +// things from it, so each names the namespaces it actually consumes. Keeping these separate matters at +// scale: a saturation-mutagenesis score set is 100k+ rows, and an unused namespace is 100k+ wasted cells. +const SCORE_SET_CHART_NAMESPACES = ['vep', 'scores', 'clingen', 'mavedb'] -function scoreSetVariantDataParams( - options: {includePostMappedHgvs?: boolean; namespaces?: string[]} = {} -): URLSearchParams { +// The variant page additionally reads the selected measurement's gnomAD frequency out of its row; this +// request is the only source of it. See `gnomadFromVariantRow`. +const VARIANT_PAGE_NAMESPACES = [...SCORE_SET_CHART_NAMESPACES, 'gnomad'] + +function scoreSetVariantDataParams(options: {namespaces?: string[]} = {}): URLSearchParams { const params = new URLSearchParams() - if (options.includePostMappedHgvs) params.append('include_post_mapped_hgvs', 'true') for (const namespace of options.namespaces ?? []) params.append('namespaces', namespace) return params } @@ -24,11 +28,14 @@ function scoreSetVariantDataUrl(urn: string, params: URLSearchParams = new URLSe return query ? `${baseUrl}?${query}` : baseUrl } -export function histogramScoreSetVariantDataUrl(urn: string): string { - return scoreSetVariantDataUrl( - urn, - scoreSetVariantDataParams({includePostMappedHgvs: true, namespaces: HISTOGRAM_VARIANT_DATA_NAMESPACES}) - ) +/** Variant data for a score set page's histogram and heatmap. */ +export function scoreSetChartVariantDataUrl(urn: string): string { + return scoreSetVariantDataUrl(urn, scoreSetVariantDataParams({namespaces: SCORE_SET_CHART_NAMESPACES})) +} + +/** Variant data for the variant page: the score distribution chart plus the selected row's annotations. */ +export function variantPageVariantDataUrl(urn: string): string { + return scoreSetVariantDataUrl(urn, scoreSetVariantDataParams({namespaces: VARIANT_PAGE_NAMESPACES})) } // --------------------------------------------------------------------------- @@ -103,17 +110,32 @@ export async function publishScoreSet(urn: string) { } export async function getScoreSetClinicalControlOptions(urn: string) { - const response = await axios.get(`${config.apiBaseUrl}/score-sets/${encodeURIComponent(urn)}/clinical-controls/options`) + const response = await axios.get( + `${config.apiBaseUrl}/score-sets/${encodeURIComponent(urn)}/clinical-controls/options` + ) return response.data } export async function downloadScoreSetFile(urn: string, type: 'scores' | 'counts'): Promise { const response = await axios.get( - `${config.apiBaseUrl}/score-sets/${encodeURIComponent(urn)}/${type}?drop_na_columns=true` + `${config.apiBaseUrl}/score-sets/${encodeURIComponent(urn)}/${type}?drop_unused_hgvs_columns=true` ) return response.data } +/** + * Fetch the CSV column namespaces this score set has data for. + */ +export async function getScoreSetCsvNamespaces( + urn: string, + signal?: AbortSignal +): Promise { + const response = await axios.get(`${config.apiBaseUrl}/score-sets/${encodeURIComponent(urn)}/csv-namespaces`, { + signal + }) + return response.data +} + export async function downloadScoreSetVariantData(urn: string, params: URLSearchParams): Promise { const response = await axios.get(scoreSetVariantDataUrl(urn, params)) return response.data @@ -121,14 +143,14 @@ export async function downloadScoreSetVariantData(urn: string, params: URLSearch export async function getScoreSetScoresPreview(urn: string): Promise { const response = await axios.get( - `${config.apiBaseUrl}/score-sets/${encodeURIComponent(urn)}/scores?drop_na_columns=true` + `${config.apiBaseUrl}/score-sets/${encodeURIComponent(urn)}/scores?drop_unused_hgvs_columns=true` ) return response.data } export async function getScoreSetCountsPreview(urn: string): Promise { const response = await axios.get( - `${config.apiBaseUrl}/score-sets/${encodeURIComponent(urn)}/counts?drop_na_columns=true` + `${config.apiBaseUrl}/score-sets/${encodeURIComponent(urn)}/counts?drop_unused_hgvs_columns=true` ) return response.data } @@ -138,7 +160,9 @@ export async function downloadMappedVariants(urn: string) { return response.data } -export async function getRecentlyPublishedScoreSets(signal?: AbortSignal): Promise { +export async function getRecentlyPublishedScoreSets( + signal?: AbortSignal +): Promise { const response = await axios.get(`${config.apiBaseUrl}/score-sets/recently-published`, { headers: {accept: 'application/json'}, signal diff --git a/src/api/mavedb/variants.ts b/src/api/mavedb/variants.ts index f0db95a4..185d131c 100644 --- a/src/api/mavedb/variants.ts +++ b/src/api/mavedb/variants.ts @@ -1,13 +1,14 @@ import axios from 'axios' import config from '@/config' -import {histogramScoreSetVariantDataUrl} from '@/api/mavedb/score-sets' +import {variantPageVariantDataUrl} from '@/api/mavedb/score-sets' import {components} from '@/schema/openapi' type ScoreSet = components['schemas']['ScoreSet'] type VariantEffectMeasurementWithScoreSet = components['schemas']['VariantEffectMeasurementWithScoreSet'] type ClingenAlleleIdVariantLookupResponse = components['schemas']['ClingenAlleleIdVariantLookupResponse'] type MappedVariant = components['schemas']['MappedVariant'] +type AvailableCsvNamespace = components['schemas']['AvailableCsvNamespace'] export async function lookupVariantsByClingenId( clingenAlleleIds: string[] @@ -30,8 +31,9 @@ export async function getVariantDetail(urn: string): Promise { - const response = await axios.get(histogramScoreSetVariantDataUrl(scoreSetUrn)) +/** The containing score set's variant table, as read by the variant page. */ +export async function getVariantPageScoreSetData(scoreSetUrn: string): Promise { + const response = await axios.get(variantPageVariantDataUrl(scoreSetUrn)) return response.data } @@ -47,3 +49,24 @@ export async function getScoreSet(urn: string): Promise { const response = await axios.get(`${config.apiBaseUrl}/score-sets/${encodeURIComponent(urn)}`) return response.data } + +/** + * Fetch the CSV column namespaces this variant has data for. + */ +export async function getVariantCsvNamespaces(urn: string, signal?: AbortSignal): Promise { + const response = await axios.get(`${config.apiBaseUrl}/variants/${encodeURIComponent(urn)}/csv-namespaces`, {signal}) + return response.data +} + +export function variantCsvUrl(urn: string, namespaces?: string[]): string { + const params = new URLSearchParams() + for (const namespace of namespaces ?? []) params.append('namespaces', namespace) + const query = params.toString() + const baseUrl = `${config.apiBaseUrl}/variants/${encodeURIComponent(urn)}/csv` + return query ? `${baseUrl}?${query}` : baseUrl +} + +export async function downloadVariantCsv(urn: string, namespaces?: string[]): Promise { + const response = await axios.get(variantCsvUrl(urn, namespaces)) + return response.data +} diff --git a/src/assets/app.css b/src/assets/app.css index 49b1e8b6..589340b1 100644 --- a/src/assets/app.css +++ b/src/assets/app.css @@ -113,12 +113,18 @@ --color-badge-alert: #fef3c7; /* ── Search Types ──────────────────────────────────────────── */ + --color-hgvs: #5c6b7a; + --color-hgvs-light: #eceff2; --color-dbsnp: #e6b84d; --color-dbsnp-light: #fdf3d7; --color-clinvar: #5aafa0; --color-clinvar-light: #c8ece5; --color-ga4gh: #0f6ca4; --color-ga4gh-light: #e3f0f9; + --color-gnomad: #5b4ba8; + --color-gnomad-light: #ece9f7; + --color-gene: #b5446e; + --color-gene-light: #fbeaf1; /* ── Measurement Types (variant screen) ────────────────────── */ --color-nucleotide: #2e7d32; diff --git a/src/components/collection/CollectionDataSetEditor.vue b/src/components/collection/CollectionDataSetEditor.vue index 5e962885..9a4c997f 100644 --- a/src/components/collection/CollectionDataSetEditor.vue +++ b/src/components/collection/CollectionDataSetEditor.vue @@ -101,7 +101,7 @@ import useItem from '@/composition/item.ts' import {addCollectionScoreSet, addCollectionExperiment} from '@/api/mavedb/collections' import {getScoreSet} from '@/api/mavedb/variants' import {getExperiment} from '@/api/mavedb/experiments' -import {getErrorResponse} from '@/api/mavedb' +import {getErrorResponse} from '@/lib/errors' import {type DataSetType, DATA_SET_TYPE_LABELS} from '@/lib/collections' import MvEmailPrompt from '@/components/common/MvEmailPrompt.vue' import {components} from '@/schema/openapi' diff --git a/src/components/common/MvCsvColumnDialog.vue b/src/components/common/MvCsvColumnDialog.vue new file mode 100644 index 00000000..afc2e983 --- /dev/null +++ b/src/components/common/MvCsvColumnDialog.vue @@ -0,0 +1,129 @@ + + + diff --git a/src/components/common/MvVariantPreview.vue b/src/components/common/MvVariantPreview.vue index 85fd7762..e0d2c4b0 100644 --- a/src/components/common/MvVariantPreview.vue +++ b/src/components/common/MvVariantPreview.vue @@ -10,28 +10,32 @@
+ @click="downloadFile('scores')" /> + @click="downloadFile('counts')" /> + @click="customDialogVisible = true" /> + + + Preparing {{ fileDownloadLabel }}… +
@@ -82,8 +86,7 @@
+ class="flex items-center justify-between border-b border-border-light border-t bg-bg px-4 py-2 tablet:px-5"> Counts Showing {{ countsRows.length }} of {{ countsData.length.toLocaleString() }} @@ -109,34 +112,26 @@
+ class="px-5 py-8 text-center text-sm italic text-text-muted"> No data available.
- - -
- -
- -
+ + + diff --git a/src/components/screens/CollectionView.vue b/src/components/screens/CollectionView.vue index e5f9afa7..78bfd8fa 100644 --- a/src/components/screens/CollectionView.vue +++ b/src/components/screens/CollectionView.vue @@ -258,7 +258,7 @@ import {deleteCollection, removeCollectionEntity, updateCollection} from '@/api/ import {useDatasetPermissions} from '@/composables/use-dataset-permissions' import type {RowAction} from '@/components/common/MvRowActionMenu.vue' import {components} from '@/schema/openapi' -import {getErrorResponse} from '@/api/mavedb' +import {getErrorResponse} from '@/lib/errors' type Collection = components['schemas']['Collection'] type User = components['schemas']['User'] diff --git a/src/components/screens/ExperimentCreator.vue b/src/components/screens/ExperimentCreator.vue index 4d11926d..407d70a0 100644 --- a/src/components/screens/ExperimentCreator.vue +++ b/src/components/screens/ExperimentCreator.vue @@ -172,7 +172,8 @@ import useScopedId from '@/composables/scoped-id' import {useExperimentKeywords} from '@/composables/use-experiment-keywords' import {useJsonFileField} from '@/composables/use-json-file-field' import {usePublicationIdentifiers} from '@/composables/use-publication-identifiers' -import {createExperiment, getErrorResponse} from '@/api/mavedb' +import {createExperiment} from '@/api/mavedb' +import {getErrorResponse} from '@/lib/errors' import useAuth from '@/composition/auth' import {useKeywordOptions} from '@/composables/use-keyword-options' import {normalizeDoiArray, normalizeRawReadArray, normalizeContributorArray} from '@/lib/form-helpers' diff --git a/src/components/screens/ExperimentEditor.vue b/src/components/screens/ExperimentEditor.vue index 590165a9..940cb461 100644 --- a/src/components/screens/ExperimentEditor.vue +++ b/src/components/screens/ExperimentEditor.vue @@ -111,7 +111,8 @@ import {useExperimentKeywords} from '@/composables/use-experiment-keywords' import {useJsonFileField} from '@/composables/use-json-file-field' import {usePublicationIdentifiers, type PublicationIdentifier} from '@/composables/use-publication-identifiers' import useAuth from '@/composition/auth' -import {updateExperiment, getErrorResponse} from '@/api/mavedb' +import {updateExperiment} from '@/api/mavedb' +import {getErrorResponse} from '@/lib/errors' import useItem from '@/composition/item.ts' import {useKeywordOptions} from '@/composables/use-keyword-options' import {normalizeDoiArray, normalizeRawReadArray, normalizeContributorArray} from '@/lib/form-helpers' diff --git a/src/components/screens/HomeScreen.vue b/src/components/screens/HomeScreen.vue index 535dac8c..cea0b314 100644 --- a/src/components/screens/HomeScreen.vue +++ b/src/components/screens/HomeScreen.vue @@ -230,6 +230,7 @@ import MvScoreSetRow from '@/components/common/MvScoreSetRow.vue' import {BROWSE_CATEGORIES, CONTRIBUTE_CATEGORIES, FEATURED_COLLECTIONS, WATERMARK_BARS} from '@/data/home' import {NEWS_ITEMS, NEWS_ITEMS_LIMIT, NEWS_TAG_STYLES} from '@/data/news' import {SEARCH_COLORS, SEARCH_PLACEHOLDERS, SEARCH_TYPES} from '@/data/search' +import {geneSymbolRegex, geneSymbolSearchTarget} from '@/lib/mavemd' import {getRecentlyPublishedScoreSets} from '@/api/mavedb/score-sets' import {components} from '@/schema/openapi' @@ -259,7 +260,7 @@ export default defineComponent({ data() { return { - searchType: 'hgvs', + searchType: 'any', searchText: '', isDesktop: false, mdQuery: null as MediaQueryList | null, @@ -271,10 +272,10 @@ export default defineComponent({ computed: { activeSearchColor(): {accent: string; bg: string; activeText?: string} { - return SEARCH_COLORS[this.searchType] || SEARCH_COLORS.hgvs + return SEARCH_COLORS[this.searchType] || SEARCH_COLORS.any }, activeSearchPlaceholder(): string { - const p = SEARCH_PLACEHOLDERS[this.searchType] || SEARCH_PLACEHOLDERS.hgvs + const p = SEARCH_PLACEHOLDERS[this.searchType] || SEARCH_PLACEHOLDERS.any return this.isDesktop ? p.full : p.short } }, @@ -305,6 +306,14 @@ export default defineComponent({ submitSearch() { const text = this.searchText.trim() if (!text) return + // A gene symbol resolves to a gene page, so go straight there. Routing through the variant search screen would + // otherwise leave a page the user never asked for sitting between the two in their history. A malformed symbol + // is left to the search screen, which owns the error message for it. + const geneSymbol = geneSymbolSearchTarget(text, this.searchType) + if (geneSymbol && geneSymbolRegex.test(geneSymbol)) { + this.router.push({name: 'gene', params: {symbol: geneSymbol}}) + return + } this.router.push({path: '/mavemd', query: {search: text, searchType: this.searchType}}) } } diff --git a/src/components/screens/ScoreSetCalibrationsView.vue b/src/components/screens/ScoreSetCalibrationsView.vue index 983695a3..373c2be7 100644 --- a/src/components/screens/ScoreSetCalibrationsView.vue +++ b/src/components/screens/ScoreSetCalibrationsView.vue @@ -232,7 +232,6 @@ import { getScoreSetCalibrations } from '@/api/mavedb' import {useDatasetPermissions} from '@/composables/use-dataset-permissions' -import axios from 'axios' import Button from 'primevue/button' import Column from 'primevue/column' import DataTable from 'primevue/datatable' @@ -242,6 +241,7 @@ import useAuth from '@/composition/auth' import {useCalibrationDialog} from '@/composables/use-calibration-dialog' import MvPageLoading from '@/components/common/MvPageLoading.vue' import CalibrationTable from '@/components/calibration/CalibrationTable.vue' +import {describeRequestError} from '@/lib/errors' import {getScoreSetShortName} from '@/lib/score-sets' import {useConfirm} from 'primevue/useconfirm' import CalibrationEditor from '@/components/calibration/CalibrationEditor.vue' @@ -258,17 +258,6 @@ type ScoreSet = components['schemas']['ScoreSet'] const CALIBRATION_ACTIONS = ['update', 'delete', 'publish', 'change_rank'] as const type CalibrationAuthorizations = Record<(typeof CALIBRATION_ACTIONS)[number], boolean> -function extractErrorDetail(error: unknown): string { - if ( - axios.isAxiosError(error) && - error.response?.data?.detail && - (typeof error.response.data.detail === 'string' || error.response.data.detail instanceof String) - ) { - return error.response.data.detail as string - } - return String(error) -} - export default { name: 'ScoreSetCalibrationsView', components: { @@ -510,7 +499,7 @@ export default { this.$toast.add({ severity: 'error', summary: 'Calibration Not Published', - detail: `An error occurred while publishing the calibration: ${extractErrorDetail(error)}. Please try again later.`, + detail: `An error occurred while publishing the calibration: ${describeRequestError(error)}. Please try again later.`, life: 4000 }) } @@ -534,7 +523,7 @@ export default { this.$toast.add({ severity: 'error', summary: 'Calibration Not Demoted', - detail: `An error occurred while demoting the calibration: ${extractErrorDetail(error)}. Please try again later.`, + detail: `An error occurred while demoting the calibration: ${describeRequestError(error)}. Please try again later.`, life: 4000 }) } @@ -555,7 +544,7 @@ export default { this.$toast.add({ severity: 'error', summary: 'Calibration Not Promoted', - detail: `An error occurred while promoting the calibration: ${extractErrorDetail(error)}. Please try again later.`, + detail: `An error occurred while promoting the calibration: ${describeRequestError(error)}. Please try again later.`, life: 4000 }) } @@ -583,7 +572,7 @@ export default { this.$toast.add({ severity: 'error', summary: 'Calibration Not Deleted', - detail: `An error occurred while deleting the calibration: ${extractErrorDetail(error)}. Please try again later.`, + detail: `An error occurred while deleting the calibration: ${describeRequestError(error)}. Please try again later.`, life: 4000 }) } @@ -610,7 +599,7 @@ export default { this.$toast.add({ severity: 'error', summary: 'Download Failed', - detail: `An error occurred while downloading the calibrations: ${extractErrorDetail(error)}. Please try again later.`, + detail: `An error occurred while downloading the calibrations: ${describeRequestError(error)}. Please try again later.`, life: 4000 }) } diff --git a/src/components/screens/ScoreSetCreator.vue b/src/components/screens/ScoreSetCreator.vue index b1756258..7a87d88e 100644 --- a/src/components/screens/ScoreSetCreator.vue +++ b/src/components/screens/ScoreSetCreator.vue @@ -405,7 +405,8 @@ import ToggleSwitch from 'primevue/toggleswitch' import {ref} from 'vue' import {useHead} from '@unhead/vue' -import {getExperiment, searchMyExperiments, createScoreSet, uploadVariantData, getErrorResponse} from '@/api/mavedb' +import {getExperiment, searchMyExperiments, createScoreSet, uploadVariantData} from '@/api/mavedb' +import {getErrorResponse} from '@/lib/errors' import CalibrationEditor from '@/components/calibration/CalibrationEditor.vue' import MvEmailPrompt from '@/components/common/MvEmailPrompt.vue' import ScoreSetContextFields from '@/components/forms/ScoreSetContextFields.vue' @@ -473,14 +474,18 @@ export default { const publications = usePublicationIdentifiers() const licenseSearch = useAutocomplete('/licenses/active') - const extractScoreSets = (data: unknown) => ((data as Record)?.scoreSets as ShortScoreSet[]) || [] + const extractPublicScoreSets = (data: unknown): ShortScoreSet[] => { + const record = data as Record + const raw = (record.scoreSets as ShortScoreSet[]) || [] + return raw.filter(sc => sc.private === false) + } const supersededSearch = useAutocomplete('/me/score-sets/search', { method: 'POST', - extract: extractScoreSets + extract: extractPublicScoreSets }) const metaAnalyzesSearch = useAutocomplete('/score-sets/search', { method: 'POST', - extract: extractScoreSets + extract: extractPublicScoreSets }) const extraMetadataField = useJsonFileField('extraMetadata') diff --git a/src/components/screens/ScoreSetEditor.vue b/src/components/screens/ScoreSetEditor.vue index 02762aee..49fbf953 100644 --- a/src/components/screens/ScoreSetEditor.vue +++ b/src/components/screens/ScoreSetEditor.vue @@ -257,7 +257,8 @@ import {useHead} from '@unhead/vue' import ToggleSwitch from 'primevue/toggleswitch' -import {searchMyExperiments, updateScoreSetWithVariants, getErrorResponse} from '@/api/mavedb' +import {searchMyExperiments, updateScoreSetWithVariants} from '@/api/mavedb' +import {getErrorResponse} from '@/lib/errors' import MvEmailPrompt from '@/components/common/MvEmailPrompt.vue' import MvEmptyState from '@/components/common/MvEmptyState.vue' import ScoreSetContextFields from '@/components/forms/ScoreSetContextFields.vue' diff --git a/src/components/screens/ScoreSetView.vue b/src/components/screens/ScoreSetView.vue index 9cd13e8a..0ff431d4 100644 --- a/src/components/screens/ScoreSetView.vue +++ b/src/components/screens/ScoreSetView.vue @@ -508,7 +508,7 @@ import { deleteScoreSet, publishScoreSet, getScoreSetClinicalControlOptions, - histogramScoreSetVariantDataUrl + scoreSetChartVariantDataUrl } from '@/api/mavedb' import {components} from '@/schema/openapi' import MvLoader from '@/components/common/MvLoader.vue' @@ -719,7 +719,7 @@ export default { this.setItemId(newValue) let scoresUrl = null if (this.itemType?.restCollectionName && this.itemId) { - scoresUrl = histogramScoreSetVariantDataUrl(this.itemId) + scoresUrl = scoreSetChartVariantDataUrl(this.itemId) } this.setScoresDataUrl(scoresUrl) this.ensureScoresDataLoaded() diff --git a/src/components/screens/SearchVariantsScreen.vue b/src/components/screens/SearchVariantsScreen.vue index 07b2eaa7..6678fc13 100644 --- a/src/components/screens/SearchVariantsScreen.vue +++ b/src/components/screens/SearchVariantsScreen.vue @@ -44,7 +44,7 @@ v-for="option in searchTypeOptions" :key="option.code" :aria-selected="searchType === option.code" - class="cursor-pointer rounded-full border-[1.5px] px-3 py-1 text-xs font-semibold transition-all md:px-4 md:py-1.5 md:text-sm" + class="cursor-pointer rounded-full border-[1.5px] px-3 py-1 text-xs font-semibold transition-all md:px-4 md:py-1.5 md:text-xs" role="tab" :style="searchColorStyle(option.code, searchType === option.code ? 'active' : 'inactive')" :tabindex="searchType === option.code ? 0 : -1" @@ -68,7 +68,7 @@ class="min-w-0 flex-1 !rounded-none !border-none !shadow-none placeholder:font-mono placeholder:text-xs md:placeholder:text-sm" :placeholder="currentPlaceholder" type="search" - @keyup.enter="defaultSearch" + @keyup.enter="defaultSearch()" /> @@ -267,6 +267,16 @@
{{ alleles.length }} allele{{ alleles.length !== 1 ? 's' : '' }} found
+ +
+ Searched as + + {{ detectedSearchTypeOption.name }} + +
+ +
+
+
+ Read as + {{ gnomadInterpretation.name }} + coordinates +
+ + {{ gnomadInterpretation.hgvs }} + +
+ +
+
-
No variants found
-
- No matching variants were found in MaveDB. Try a different identifier or search type. -
+ + +
- +
- + {{ allele.canonicalAlleleName }}
@@ -322,17 +395,14 @@
- + View variant → -
-
-
- - {{ allele.canonicalAlleleName }} - -
-
+ Not in the ClinGen registry + @@ -676,6 +746,10 @@ import { type AlleleResult, clinGenAlleleIdRegex, clinVarVariationIdRegex, + detectSearchType, + geneSymbolRegex, + geneSymbolSearchTarget, + gnomadIdRegex, rsIdRegex, vrsDigestRegex, scoreSetUrnFromVariantUrn, @@ -685,8 +759,9 @@ import { import {getTargetGeneName} from '@/lib/target-genes' import {components} from '@/schema/openapi' import {getScoreSetShortName} from '@/lib/score-sets' +import {type GenomeAssembly, GENOME_ASSEMBLY_NAMES, gnomadIdToHgvs, otherAssembly} from '@/lib/gnomad' import {clinVarHgvsSearchStringRegex, hgvsSearchStringRegex} from '@/lib/mave-hgvs' -import {SEARCH_COLORS} from '@/data/search' +import {SEARCH_COLORS, SEARCH_PLACEHOLDERS} from '@/data/search' import {AVE_CLINICAL_APPLICATION} from '@/lib/links' import { HOW_IT_WORKS_STEPS, @@ -695,8 +770,16 @@ import { VARIANT_TYPE_OPTIONS, ALLELE_OPTIONS } from '@/data/mavemd' -import {getAlleleByCaId, getAlleleByHgvs, getAlleleByDbSnp, getAlleleByClinVar, getGeneBySymbol} from '@/api/clingen' -import {getCollection, getErrorResponse, lookupVariantsByClingenId} from '@/api/mavedb' +import { + getAlleleByCaId, + getAlleleByHgvs, + getAlleleByDbSnp, + getAlleleByClinVar, + getAlleleByGnomad, + getGeneBySymbol +} from '@/api/clingen' +import {getCollection, lookupVariantsByClingenId} from '@/api/mavedb' +import {getErrorResponse} from '@/lib/errors' import {lookupVariantsByVrsDigest} from '@/api/mavedb/variants' import {useEntityCache} from '@/composables/entity-cache' import MvLoader from '@/components/common/MvLoader.vue' @@ -748,6 +831,17 @@ export default defineComponent({ inputAlternateAllele: null as string | null, allAlleleOptions: ALLELE_OPTIONS, alleles: [] as AlleleResult[], + /** + * The gnomAD reading currently on screen: the ID exactly as searched, the assembly it was read (or attempted) + * under, and how the registry answered. A null mismatch means no verdict on the assembly — either the reading + * resolved, or it failed for a reason that says nothing about the coordinates. + * + * This records what the last search did, rather than tracking the form: the results must not restate + * themselves as the user types a new identifier into the box. + */ + gnomadReading: null as {id: string; assembly: GenomeAssembly; mismatch: 'reference' | 'position' | null} | null, + /** What an "Any" search resolved the string to; null when the search type was chosen explicitly. */ + detectedSearchType: null as string | null, nucleotideScoreSetListIsExpanded: [] as Array, proteinScoreSetListIsExpanded: [] as Array, associatedNucleotideScoreSetListIsExpanded: [] as Array, @@ -800,11 +894,41 @@ export default defineComponent({ ) }, currentSearchColor(): {accent: string; bg: string; activeText?: string} { - return SEARCH_COLORS[this.searchType || 'hgvs'] || SEARCH_COLORS.hgvs + return SEARCH_COLORS[this.searchType || 'any'] || SEARCH_COLORS.any }, currentPlaceholder(): string { + // A specific search type prompts with one of its own examples, but "Any" accepts too many shapes for one + // example to describe, so it shares the homepage's wording instead. + if (this.searchType === 'any') { + return SEARCH_PLACEHOLDERS.any.full + } const option = this.searchTypeOptions.find((o) => o.code === this.searchType) return option?.examples?.[0] || 'Enter a value' + }, + /** The search type an "Any" search resolved to, for display alongside the results. */ + detectedSearchTypeOption(): {code: string; name: string} | null { + if (!this.detectedSearchType) { + return null + } + return this.searchTypeOptions.find((option) => option.code === this.detectedSearchType) ?? null + }, + /** How the gnomAD ID that was searched got read, how it would read under the other assembly, and how that went. */ + gnomadInterpretation(): { + id: string + name: string + hgvs: string | null + otherName: string + mismatch: 'reference' | 'position' | null + } | null { + if (!this.gnomadReading) return null + const {id, assembly, mismatch} = this.gnomadReading + return { + id, + name: GENOME_ASSEMBLY_NAMES[assembly], + hgvs: gnomadIdToHgvs(id, assembly), + otherName: GENOME_ASSEMBLY_NAMES[otherAssembly(assembly)], + mismatch + } } }, @@ -846,7 +970,7 @@ export default defineComponent({ if (typeof newVal === 'string') { this.searchType = newVal } else if (!newVal && this.defaultSearchVisible) { - this.searchType = 'hgvs' + this.searchType = 'any' } } }, @@ -985,11 +1109,22 @@ export default defineComponent({ this.maveMdScoreSetsError = true } }, - searchForText: function (example: string, searchType: string) { + searchForText: async function (example: string, searchType: string) { + // A gene symbol example is really a link to its gene page, so navigate before touching any search state. + // Selecting a type and resetting the form queues watchers that clear the search text and rewrite the query + // params, and those rewrites would abort the navigation out from under us. + const geneSymbol = geneSymbolSearchTarget(example, searchType) + if (geneSymbol && geneSymbolRegex.test(geneSymbol)) { + this.router.push({name: 'gene', params: {symbol: geneSymbol}}) + return + } this.searchType = searchType this.showSearch('default') + // Let the search type watcher run before the box is filled. It clears the box on a type change, and queued as + // it is, it would otherwise flush during the search below and wipe out the example we just put there. + await this.$nextTick() this.searchText = example - this.defaultSearch() + await this.defaultSearch() this.router.replace({query: {searchType: this.searchType, search: this.searchText}}) }, clearSearch() { @@ -1001,6 +1136,8 @@ export default defineComponent({ this.inputAlternateAllele = null this.searchResultsVisible = false this.alleles = [] + this.gnomadReading = null + this.detectedSearchType = null this.router.replace({query: {}}) }, showSearch(searchMethod: 'guided' | 'default' = 'default') { @@ -1012,7 +1149,7 @@ export default defineComponent({ delete query.search delete query.searchType query.mode = 'guided' - this.searchType = 'hgvs' + this.searchType = 'any' } else { delete query.gene delete query.variantType @@ -1024,9 +1161,36 @@ export default defineComponent({ this.router.replace({query}) this.clearSearch() }, - defaultSearch: async function () { + defaultSearch: async function (forcedAssembly?: GenomeAssembly) { if (this.searchText) this.searchText = this.searchText.trim() + + // A gene symbol navigates away instead of producing results here, so it is handled before this screen records + // the search in its query params. The search is also stripped from the entry being left behind: were it to + // remain, returning here would re-run it on mount and bounce the user straight back to the gene page. + const geneSymbol = geneSymbolSearchTarget(this.searchText ?? '', this.searchType) + if (geneSymbol) { + if (!geneSymbolRegex.test(geneSymbol)) { + this.toast.add({ + severity: 'error', + summary: 'Invalid search', + detail: `Please provide a valid gene symbol (e.g. ${this.searchTypeOptions.find((o) => o.code === 'geneSymbol')?.examples?.join(', ')})`, + life: 10000 + }) + return + } + const query = {...this.route.query} + delete query.search + await this.router.replace({query}) + this.router.push({name: 'gene', params: {symbol: geneSymbol}}) + return + } + this.searchResultsVisible = true + // Record the assembly being attempted, so a forced reading that fails to resolve still reports what was + // tried rather than leaving the previous, now-discarded result on screen. + this.gnomadReading = + forcedAssembly && this.searchText ? {id: this.searchText, assembly: forcedAssembly, mismatch: null} : null + this.detectedSearchType = null const query = {...this.route.query} delete query.mode delete query.gene @@ -1043,15 +1207,47 @@ export default defineComponent({ this.alleles = [] this.loading = true if (this.searchText !== null && this.searchText !== '') { - await this.fetchDefaultSearchResults(this.searchText) + await this.fetchDefaultSearchResults(this.searchText, null, undefined, forcedAssembly) } this.loading = false await this.searchVariants() }, - fetchDefaultSearchResults: async function (searchString: string, maneStatus: string | null = null, forcedSearchType?: string) { - const searchType = forcedSearchType ?? this.searchType + /** + * Re-read the current gnomAD ID under the other assembly. + * + * The same coordinates can be valid under both assemblies while naming a different variant in each, so this lets + * the user correct an ID that resolved under the wrong one. + */ + switchGnomadAssembly: async function () { + if (!this.gnomadReading) return + await this.defaultSearch(otherAssembly(this.gnomadReading.assembly)) + }, + fetchDefaultSearchResults: async function ( + searchString: string, + maneStatus: string | null = null, + forcedSearchType?: string, + forcedAssembly?: GenomeAssembly | null + ) { + let searchType = forcedSearchType ?? this.searchType let searchStr = searchString.trim() + // The "Any" type is a stand-in: work out what the string actually is, then take the usual path for that type. + if (searchType === 'any') { + const detectedSearchType = detectSearchType(searchStr) + if (!detectedSearchType) { + this.toast.add({ + severity: 'error', + summary: 'Unrecognized identifier', + detail: `${searchStr} does not look like any supported variant identifier or gene symbol. Choose a search type to see examples of what is accepted.`, + life: 10000 + }) + return + } + searchType = detectedSearchType + // Record it for display: the selected type stays "Any", but the user should see what it was taken to be. + this.detectedSearchType = detectedSearchType + } + try { let responseData if (searchType === 'clinGenAlleleId') { @@ -1087,6 +1283,72 @@ export default defineComponent({ return } responseData = await getAlleleByClinVar(searchStr) + } else if (searchType === 'gnomadId') { + if (!gnomadIdRegex.test(searchStr)) { + this.toast.add({ + severity: 'error', + summary: 'Invalid search', + detail: `Please provide a valid gnomAD variant ID (e.g. ${this.searchTypeOptions.find((o) => o.code === searchType)?.examples?.join(', ')})`, + life: 10000 + }) + return + } + try { + const gnomadResult = await getAlleleByGnomad(searchStr, forcedAssembly ?? undefined) + this.gnomadReading = {id: searchStr, assembly: gnomadResult.assembly, mismatch: null} + responseData = gnomadResult.allele + } catch (error: unknown) { + // Coordinates that don't fit an assembly aren't a failure to report as one: that is the answer. Which way + // they fail to fit decides what can be said about them, so classify it and hand it to the template too, + // rather than confining it to a toast that has to guess. + const {data} = getErrorResponse(error) + const mismatch = + data?.errorType === 'IncorrectReferenceAllele' + ? 'reference' + : data?.errorType === 'IncorrectHgvsPosition' + ? 'position' + : null + if (!mismatch) throw error + if (this.gnomadReading) this.gnomadReading.mismatch = mismatch + + const actualAllele = data?.actualAllele as string | undefined + const givenAllele = data?.givenAllele as string | undefined + const reason = + mismatch === 'position' + ? 'That position lies past the end of the chromosome there.' + : actualAllele && givenAllele + ? `The reference base at that position is ${actualAllele}, not ${givenAllele}.` + : '' + this.toast.add( + forcedAssembly + ? { + severity: 'warn', + summary: `Not ${GENOME_ASSEMBLY_NAMES[forcedAssembly]} coordinates`, + detail: [ + `${searchStr} cannot be read as ${GENOME_ASSEMBLY_NAMES[forcedAssembly]}.`, + reason, + `It may only be valid as ${GENOME_ASSEMBLY_NAMES[otherAssembly(forcedAssembly)]}.` + ] + .filter(Boolean) + .join(' '), + life: 10000 + } + : { + severity: 'warn', + summary: 'Variant not found', + detail: [ + `${searchStr} does not fit the reference sequence in either ` + + `${GENOME_ASSEMBLY_NAMES.grch38} or ${GENOME_ASSEMBLY_NAMES.grch37}.`, + reason, + 'Check the position and reference allele.' + ] + .filter(Boolean) + .join(' '), + life: 10000 + } + ) + return + } } else if (searchType === 'vrsDigest') { if (!vrsDigestRegex.test(searchStr)) { this.toast.add({ @@ -1179,6 +1441,15 @@ export default defineComponent({ } break } + } else if (result.genomicAlleles?.length || result.transcriptAlleles?.length) { + // The registry resolved these coordinates against the reference but holds no registered allele for them, + // so it answers with a blank node id (_:CA) rather than a CA id. Show what the coordinates resolve to + // instead of discarding it; an unregistered allele can have no MaveDB measurements either way. + const unregisteredAllele = createAlleleResult(result, maneStatus) + unregisteredAllele.clingenAlleleId = undefined + unregisteredAllele.clingenAlleleUrl = undefined + unregisteredAllele.variantsStatus = 'Loaded' + this.alleles.push(unregisteredAllele) } } if (this.alleles.length > 0) { @@ -1258,6 +1529,7 @@ export default defineComponent({ this.searchResultsVisible = true this.syncGuidedQueryParams() this.alleles = [] + this.detectedSearchType = null this.loading = true await this.fetchGuidedSearchResults() this.loading = false @@ -1329,7 +1601,7 @@ export default defineComponent({ }, searchColorStyle(code: string, variant: 'active' | 'inactive' | 'chip' = 'chip'): Record { - const colors = SEARCH_COLORS[code] || SEARCH_COLORS.hgvs + const colors = SEARCH_COLORS[code] || SEARCH_COLORS.any switch (variant) { case 'active': return { diff --git a/src/components/screens/SettingsScreen.vue b/src/components/screens/SettingsScreen.vue index ccd13d48..cade549b 100644 --- a/src/components/screens/SettingsScreen.vue +++ b/src/components/screens/SettingsScreen.vue @@ -153,15 +153,8 @@ import {defineComponent} from 'vue' import {useHead} from '@unhead/vue' import {useRouter} from 'vue-router' -import { - createAccessKey, - createRoleAccessKey, - deleteAccessKey, - getErrorResponse, - getMyCollections, - searchMyExperiments, - searchMyScoreSets -} from '@/api/mavedb' +import {createAccessKey, createRoleAccessKey, deleteAccessKey, getMyCollections, searchMyExperiments, searchMyScoreSets} from '@/api/mavedb' +import {getErrorResponse} from '@/lib/errors' import MvAccessKeyRow from '@/components/common/MvAccessKeyRow.vue' import MvOrcidLink from '@/components/common/MvOrcidLink.vue' import MvFloatField from '@/components/forms/MvFloatField.vue' diff --git a/src/components/screens/VariantScreen.vue b/src/components/screens/VariantScreen.vue index 14239880..7abdfb21 100644 --- a/src/components/screens/VariantScreen.vue +++ b/src/components/screens/VariantScreen.vue @@ -3,21 +3,45 @@ @@ -280,8 +316,10 @@ import MvPageLoading from '@/components/common/MvPageLoading.vue' import MvLayout from '@/components/layout/MvLayout.vue' import MvPageHeader from '@/components/layout/MvPageHeader.vue' import MvAssayFactsCard from '@/components/common/MvAssayFactsCard.vue' +import MvCsvColumnDialog from '@/components/common/MvCsvColumnDialog.vue' import MvBadgeToggle from '@/components/common/MvBadgeToggle.vue' import ScoreSetHistogram from '@/components/score-set/ScoreSetHistogram.vue' +import MvGnomadSummary from '@/components/variant/MvGnomadSummary.vue' import MvMeasurementCard from '@/components/variant/MvMeasurementCard.vue' import MvRowActionMenu, {type RowAction} from '@/components/common/MvRowActionMenu.vue' import VariantInfoSection from '@/components/variant/VariantInfoSection.vue' @@ -289,6 +327,12 @@ import {useVariantLookup} from '@/composables/use-variant-lookup' import {MEASUREMENT_TYPE_LABELS} from '@/lib/measurement-types' import {hasFunctionalCalibrations, hasPathogenicityCalibrations} from '@/lib/calibrations' +/** One entry in a download control: a label and the download it triggers. */ +interface DownloadOption { + label: string + command: () => void +} + export default defineComponent({ name: 'VariantScreen', @@ -300,12 +344,14 @@ export default defineComponent({ MvEmptyState, MvErrorState, MvEvidenceTag, + MvGnomadSummary, MvLayout, MvLoader, MvMeasurementCard, MvPageHeader, MvRowActionMenu, MvPageLoading, + MvCsvColumnDialog, PSelect: Select, ScoreSetHistogram, SplitButton, @@ -326,7 +372,17 @@ export default defineComponent({ } }, + data() { + return { + csvDialogVisible: false + } + }, + computed: { + /** Any download in flight; the buttons stay disabled until it settles. */ + downloadInProgress(): boolean { + return this.lookup.downloadInProgressLabel.value !== null + }, pageTitle(): string { return this.lookup.clingenAllele.alleleName.value || this.lookup.selectedVariantName.value || 'Variant' }, @@ -338,14 +394,44 @@ export default defineComponent({ urn: variant.content.urn })) }, - downloadActions(): RowAction[] { - return this.annotatedVariantDownloadOptions.map((opt) => ({ - label: opt.label, - handler: opt.command - })) + /** + * The flat table download: one CSV of this allele's measurements. + * + * Always offered. Unlike the VA-Spec objects it needs no mapping, score, or calibration — an + * unmapped variant still has an identity, a score, and provenance worth exporting. + */ + tableDownloadOptions(): DownloadOption[] { + return [ + {label: 'Download variant CSV', command: () => this.lookup.downloadVariantCsvFile()}, + { + label: 'Choose CSV columns…', + command: () => { + this.csvDialogVisible = true + } + } + ] + }, + + /** What clicking the table button itself does: download with the default column set. */ + primaryTableDownload(): DownloadOption { + return this.tableDownloadOptions[0] + }, + + /** What its caret offers: everything beyond the one-click default. */ + tableDownloadMenu(): DownloadOption[] { + return this.tableDownloadOptions.slice(1) }, - annotatedVariantDownloadOptions(): {label: string; command: () => void}[] { - const options: {label: string; command: () => void}[] = [] + + /** + * The VA-Spec downloads: nested, standards-compliant annotation objects. + * + * A different product from the table — machine-readable GA4GH structures rather than something to + * open in a spreadsheet — so they get their own control rather than sharing a menu. Each requires + * progressively more of the variant to exist, so the list is empty for an unmapped variant and the + * button is hidden entirely. + */ + annotationDownloadOptions(): DownloadOption[] { + const options: DownloadOption[] = [] const activeVariant = this.lookup.selectedVariantDetail.value if (!activeVariant) return options @@ -375,6 +461,21 @@ export default defineComponent({ } return options + }, + + /** What clicking the annotations button itself does: the highest-level statement available. */ + primaryAnnotationDownload(): DownloadOption | undefined { + return this.annotationDownloadOptions[0] + }, + + /** The narrow-screen equivalent: both groups in one menu, separated so they stay distinguishable. */ + downloadActions(): RowAction[] { + const asAction = (option: DownloadOption): RowAction => ({label: option.label, handler: option.command}) + const annotations = this.annotationDownloadOptions + return [ + ...this.tableDownloadOptions.map(asAction), + ...(annotations.length > 0 ? [{separator: true} as RowAction, ...annotations.map(asAction)] : []) + ] } }, @@ -384,7 +485,11 @@ export default defineComponent({ } }, - methods: {} + methods: { + downloadSelectedCsv({namespaces}: {namespaces: string[]}) { + this.lookup.downloadVariantCsvFile(namespaces) + } + } }) diff --git a/src/components/variant/MvGnomadSummary.vue b/src/components/variant/MvGnomadSummary.vue new file mode 100644 index 00000000..f806df54 --- /dev/null +++ b/src/components/variant/MvGnomadSummary.vue @@ -0,0 +1,55 @@ + + + diff --git a/src/composables/use-csv-namespaces.test.ts b/src/composables/use-csv-namespaces.test.ts new file mode 100644 index 00000000..2287ff6b --- /dev/null +++ b/src/composables/use-csv-namespaces.test.ts @@ -0,0 +1,559 @@ +import {CanceledError} from 'axios' +import {beforeEach, describe, expect, it, vi} from 'vitest' +import {ref} from 'vue' + +import {useCsvNamespaces, type AvailableCsvNamespace, type CsvNamespaceSection} from './use-csv-namespaces' + +const getScoreSetCsvNamespaces = vi.fn() +const getVariantCsvNamespaces = vi.fn() + +vi.mock('@/api/mavedb', () => ({ + getScoreSetCsvNamespaces: (...args: unknown[]) => getScoreSetCsvNamespaces(...args), + getVariantCsvNamespaces: (...args: unknown[]) => getVariantCsvNamespaces(...args) +})) + +function entry( + overrides: Partial & { + selectedByDefault?: boolean + scoreSet?: {urn: string; title: string} + } +): AvailableCsvNamespace { + return { + namespace: 'scores', + label: 'Scores', + group: 'data', + ...overrides + } as AvailableCsvNamespace +} + +const SCORE_SET_ENTRIES: AvailableCsvNamespace[] = [ + entry({namespace: 'scores', label: 'Scores', group: 'data'}), + entry({namespace: 'gnomad', label: 'gnomAD population frequency', group: 'annotation'}), + entry({namespace: 'clinvar.2024_11', label: 'ClinVar significance (November 2024)', group: 'annotation'}), + entry({namespace: 'calibration.urn:mavedb:calibration-1', label: 'Brnich et al. 2019', group: 'calibration'}), + entry({namespace: 'score_set', label: 'Score set and publications', group: 'provenance'}) +] + +beforeEach(() => { + getScoreSetCsvNamespaces.mockReset() + getVariantCsvNamespaces.mockReset() +}) + +describe('useCsvNamespaces', () => { + it('does not fetch until load is called', () => { + useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + expect(getScoreSetCsvNamespaces).not.toHaveBeenCalled() + }) + + it('loads score set namespaces from the score set endpoint', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {namespaces, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + expect(getScoreSetCsvNamespaces).toHaveBeenCalledOnce() + expect(getVariantCsvNamespaces).not.toHaveBeenCalled() + expect(namespaces.value).toHaveLength(5) + }) + + it('loads variant namespaces from the variant endpoint', async () => { + getVariantCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1#1'), kind: 'variant'}) + + await load() + + expect(getVariantCsvNamespaces).toHaveBeenCalledOnce() + expect(getScoreSetCsvNamespaces).not.toHaveBeenCalled() + }) + + it('groups namespaces into ordered sections, omitting empty ones', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {sections, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + expect(sections.value.map((section) => section.group)).toEqual(['data', 'annotation', 'calibration', 'provenance']) + expect(sections.value[1].namespaces.map((entry) => entry.namespace)).toEqual(['gnomad', 'clinvar.2024_11']) + expect(sections.value[2].title).toBe('Clinical interpretation') + }) + + it('omits sections with no namespaces', async () => { + getScoreSetCsvNamespaces.mockResolvedValue([entry({namespace: 'scores', group: 'data'})]) + const {sections, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + expect(sections.value.map((section) => section.group)).toEqual(['data']) + }) + + it('serves labels from the API rather than deriving them', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {namespaces, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + const calibration = namespaces.value.find((n) => n.namespace.startsWith('calibration.')) + expect(calibration?.label).toBe('Brnich et al. 2019') + const clinvar = namespaces.value.find((n) => n.namespace.startsWith('clinvar.')) + expect(clinvar?.label).toBe('ClinVar significance (November 2024)') + }) + + it('caches per URN so reopening a dialog does not refetch', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + await load() + + expect(getScoreSetCsvNamespaces).toHaveBeenCalledOnce() + }) + + it('refetches when the URN changes', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const urn = ref('urn:mavedb:00000001-a-1') + const {load} = useCsvNamespaces({urn, kind: 'scoreSet'}) + + await load() + urn.value = 'urn:mavedb:00000001-a-2' + await load() + + expect(getScoreSetCsvNamespaces).toHaveBeenCalledTimes(2) + }) + + it('does not fetch without a URN', async () => { + const {load} = useCsvNamespaces({urn: ref(null), kind: 'scoreSet'}) + + await load() + + expect(getScoreSetCsvNamespaces).not.toHaveBeenCalled() + }) + + it('reports an error and offers nothing when the request fails', async () => { + getScoreSetCsvNamespaces.mockRejectedValue(new Error('boom')) + const {namespaces, hasNamespaces, error, loading, load} = useCsvNamespaces({ + urn: ref('urn:mavedb:00000001-a-1'), + kind: 'scoreSet' + }) + + await load() + + expect(error.value).toBeTruthy() + expect(namespaces.value).toEqual([]) + expect(hasNamespaces.value).toBe(false) + expect(loading.value).toBe(false) + }) + + it('retries after a failure rather than caching the empty result', async () => { + getScoreSetCsvNamespaces.mockRejectedValueOnce(new Error('boom')).mockResolvedValue(SCORE_SET_ENTRIES) + const {hasNamespaces, error, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + await load() + + expect(getScoreSetCsvNamespaces).toHaveBeenCalledTimes(2) + expect(error.value).toBeNull() + expect(hasNamespaces.value).toBe(true) + }) + + it('ignores an aborted request instead of reporting it as an error', async () => { + getScoreSetCsvNamespaces.mockRejectedValue(new CanceledError('canceled')) + const {error, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + expect(error.value).toBeNull() + }) + + it('a superseded request neither clears the spinner nor drops the live request', async () => { + // The abort lands while the second request is still in flight. If the first one's cleanup ran + // unguarded, the dialog would stop showing a spinner and reset() could no longer abort what is + // actually loading. + let resolveSecond: (entries: AvailableCsvNamespace[]) => void = () => {} + getScoreSetCsvNamespaces + .mockRejectedValueOnce(new CanceledError('canceled')) + .mockReturnValueOnce(new Promise((resolve) => (resolveSecond = resolve))) + + const urn = ref('urn:mavedb:00000001-a-1') + const {hasNamespaces, loading, load, reset} = useCsvNamespaces({urn, kind: 'scoreSet'}) + + const first = load() + urn.value = 'urn:mavedb:00000001-a-2' + const second = load() + await first + + expect(loading.value).toBe(true) + + reset() + resolveSecond(SCORE_SET_ENTRIES) + await second + + // reset() aborted the live request, so its result must not land after the fact. + expect(hasNamespaces.value).toBe(false) + }) + + it('does not count a formatting extra as a column group', async () => { + // The Download button is enabled from this count, and a formatting flag produces no columns — that is + // how an empty `namespaces` request used to slip through and come back as the API's default. + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const extraOptions = ref([{label: "Omit HGVS columns this score set doesn't use", value: 'dropUnusedHgvs'}]) + const {selected, selectedExtras, selectedColumnGroups, load} = useCsvNamespaces({ + urn: ref('urn:mavedb:00000001-a-1'), + kind: 'scoreSet', + extraOptions + }) + + await load() + selected.value = [] + selectedExtras.value = ['dropUnusedHgvs'] + + expect(selectedColumnGroups.value).toBe(0) + }) + + it('reset clears loaded namespaces', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {hasNamespaces, load, reset} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + expect(hasNamespaces.value).toBe(true) + + reset() + + expect(hasNamespaces.value).toBe(false) + }) +}) + +describe('useCsvNamespaces selection', () => { + it('selects everything once the list loads', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {selected, allSelected, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + expect(allSelected.value).toBe(true) + expect(selected.value).toEqual(SCORE_SET_ENTRIES.map((entry) => entry.namespace)) + }) + + it('starts with nothing selected before loading', () => { + const {selected, allSelected} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + expect(selected.value).toEqual([]) + expect(allSelected.value).toBe(false) + }) + + it('toggleAll clears a full selection', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {selected, allSelected, toggleAll, load} = useCsvNamespaces({ + urn: ref('urn:mavedb:00000001-a-1'), + kind: 'scoreSet' + }) + await load() + + toggleAll() + + expect(selected.value).toEqual([]) + expect(allSelected.value).toBe(false) + }) + + it('toggleAll restores everything from an empty selection', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {selected, toggleAll, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + await load() + + toggleAll() + toggleAll() + + expect(selected.value).toHaveLength(SCORE_SET_ENTRIES.length) + }) + + it('toggleAll selects everything from a partial selection', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {selected, toggleAll, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + await load() + selected.value = ['scores'] + + toggleAll() + + expect(selected.value).toHaveLength(SCORE_SET_ENTRIES.length) + }) + + it('keeps a narrowed selection when the dialog is reopened for the same record', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {selected, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + await load() + selected.value = ['scores'] + + await load() + + expect(selected.value).toEqual(['scores']) + }) + + it('reselects everything when the record changes', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const urn = ref('urn:mavedb:00000001-a-1') + const {selected, load} = useCsvNamespaces({urn, kind: 'scoreSet'}) + await load() + selected.value = ['scores'] + + urn.value = 'urn:mavedb:00000001-a-2' + await load() + + expect(selected.value).toHaveLength(SCORE_SET_ENTRIES.length) + }) + + it('clears the selection when loading fails', async () => { + getScoreSetCsvNamespaces.mockResolvedValueOnce(SCORE_SET_ENTRIES).mockRejectedValueOnce(new Error('boom')) + const urn = ref('urn:mavedb:00000001-a-1') + const {selected, load} = useCsvNamespaces({urn, kind: 'scoreSet'}) + await load() + + urn.value = 'urn:mavedb:00000001-a-2' + await load() + + expect(selected.value).toEqual([]) + }) + + it('summarizes the selection so the picker states it outright', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(SCORE_SET_ENTRIES) + const {selected, selectionSummary, load} = useCsvNamespaces({ + urn: ref('urn:mavedb:00000001-a-1'), + kind: 'scoreSet' + }) + await load() + expect(selectionSummary.value).toBe('All columns selected') + + selected.value = ['scores', 'gnomad'] + expect(selectionSummary.value).toBe('2 of 5 column groups selected') + + selected.value = [] + expect(selectionSummary.value).toBe('No columns selected') + }) +}) + +describe('useCsvNamespaces default selection', () => { + const WITH_RESEARCH_USE_ONLY: AvailableCsvNamespace[] = [ + entry({namespace: 'scores', label: 'Scores', group: 'data'}), + entry({ + namespace: 'calibration.urn:mavedb:calibration-1', + label: 'Brnich et al. 2019', + group: 'calibration' + }), + entry({ + namespace: 'calibration.urn:mavedb:calibration-2', + label: 'Research Use Only: Provisional', + group: 'calibration', + selectedByDefault: false + }) + ] + + it('leaves research-use-only calibrations unchecked on load', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(WITH_RESEARCH_USE_ONLY) + const {selected, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + expect(selected.value).toEqual(['scores', 'calibration.urn:mavedb:calibration-1']) + }) + + it('still offers them, so a user can opt in', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(WITH_RESEARCH_USE_ONLY) + const {namespaces, sections, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + expect(namespaces.value).toHaveLength(3) + const calibrationSection = sections.value.find((section) => section.group === 'calibration') + expect(calibrationSection?.namespaces.map((entry) => entry.label)).toContain('Research Use Only: Provisional') + }) + + it('is not fully selected on load when a group is excluded by default', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(WITH_RESEARCH_USE_ONLY) + const {allSelected, selectionSummary, load} = useCsvNamespaces({ + urn: ref('urn:mavedb:00000001-a-1'), + kind: 'scoreSet' + }) + + await load() + + expect(allSelected.value).toBe(false) + expect(selectionSummary.value).toBe('2 of 3 column groups selected') + }) + + it('select all reaches research-use-only groups, since that is an explicit act', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(WITH_RESEARCH_USE_ONLY) + const {selected, allSelected, toggleAll, load} = useCsvNamespaces({ + urn: ref('urn:mavedb:00000001-a-1'), + kind: 'scoreSet' + }) + await load() + + toggleAll() + + expect(allSelected.value).toBe(true) + expect(selected.value).toContain('calibration.urn:mavedb:calibration-2') + }) +}) + +describe('useCsvNamespaces extras', () => { + const NAMESPACE_ENTRIES: AvailableCsvNamespace[] = [ + entry({namespace: 'scores', label: 'Score', group: 'data'}), + entry({namespace: 'scores_custom', label: 'Investigator-provided score columns', group: 'data'}), + entry({namespace: 'gnomad', label: 'gnomAD population frequency', group: 'annotation'}) + ] + + const EXTRAS = [{label: "Omit HGVS columns this score set doesn't use", value: 'dropUnusedHgvsColumns'}] + + function withExtras() { + return useCsvNamespaces({ + urn: ref('urn:mavedb:00000001-a-1'), + kind: 'scoreSet', + extraOptions: ref(EXTRAS) + }) + } + + it('counts only namespaces as column groups', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(NAMESPACE_ENTRIES) + const {totalColumnGroups, load} = withExtras() + + await load() + + // The investigator's score columns are a namespace now, so all three count; the flag does not. + expect(totalColumnGroups.value).toBe(3) + }) + + it('treats the investigator score columns as an ordinary namespace', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(NAMESPACE_ENTRIES) + const {selected, load} = withExtras() + + await load() + + expect(selected.value).toContain('scores') + expect(selected.value).toContain('scores_custom') + }) + + it('is still "all selected" while a formatting extra is unchecked', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(NAMESPACE_ENTRIES) + const {selectedExtras, allSelected, load} = withExtras() + await load() + + selectedExtras.value = [] + + expect(allSelected.value).toBe(true) + }) + + it('leaves formatting extras unchecked on load', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(NAMESPACE_ENTRIES) + const {selectedExtras, load} = withExtras() + + await load() + + expect(selectedExtras.value).toEqual([]) + }) + + it('select all does not disturb formatting options', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(NAMESPACE_ENTRIES) + const {selectedExtras, toggleAll, load} = withExtras() + await load() + selectedExtras.value = ['dropUnusedHgvsColumns'] + + toggleAll() + toggleAll() + + expect(selectedExtras.value).toEqual(['dropUnusedHgvsColumns']) + }) + + it('exposes formatting options for the Options section', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(NAMESPACE_ENTRIES) + const {formattingExtraOptions, load} = withExtras() + + await load() + + expect(formattingExtraOptions.value.map((o) => o.value)).toEqual(['dropUnusedHgvsColumns']) + }) +}) + +describe('useCsvNamespaces score set subdivision', () => { + const ONE_SCORE_SET: AvailableCsvNamespace[] = [ + entry({namespace: 'scores', group: 'data'}), + entry({ + namespace: 'calibration.urn:mavedb:calibration-1', + label: 'First Assay Calibration', + group: 'calibration', + scoreSet: {urn: 'urn:mavedb:00000001-a-1', title: 'First Assay'} + }) + ] + + const TWO_SCORE_SETS: AvailableCsvNamespace[] = [ + ...ONE_SCORE_SET, + entry({ + namespace: 'calibration.urn:mavedb:calibration-2', + label: 'Second Assay Calibration', + group: 'calibration', + scoreSet: {urn: 'urn:mavedb:00000001-a-2', title: 'Second Assay'} + }) + ] + + function sectionFor(sections: CsvNamespaceSection[], group: string) { + return sections.find((section) => section.group === group) + } + + it('does not subdivide when only one score set is represented', async () => { + getScoreSetCsvNamespaces.mockResolvedValue(ONE_SCORE_SET) + const {sections, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1'), kind: 'scoreSet'}) + + await load() + + const calibrations = sectionFor(sections.value, 'calibration') + expect(calibrations?.subsections).toHaveLength(1) + expect(calibrations?.subsections[0].label).toBeNull() + }) + + it('subdivides by score set once more than one is represented', async () => { + getVariantCsvNamespaces.mockResolvedValue(TWO_SCORE_SETS) + const {sections, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1#1'), kind: 'variant'}) + + await load() + + const calibrations = sectionFor(sections.value, 'calibration') + // Headed by title, not URN — but the URN comes along for disambiguation. + expect(calibrations?.subsections.map((s) => s.label)).toEqual(['First Assay', 'Second Assay']) + expect(calibrations?.subsections.map((s) => s.urn)).toEqual(['urn:mavedb:00000001-a-1', 'urn:mavedb:00000001-a-2']) + expect(calibrations?.subsections[0].namespaces.map((e) => e.label)).toEqual(['First Assay Calibration']) + expect(calibrations?.subsections[1].namespaces.map((e) => e.label)).toEqual(['Second Assay Calibration']) + }) + + it('never subdivides sections whose namespaces have no owning score set', async () => { + getVariantCsvNamespaces.mockResolvedValue(TWO_SCORE_SETS) + const {sections, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1#1'), kind: 'variant'}) + + await load() + + const data = sectionFor(sections.value, 'data') + expect(data?.subsections).toHaveLength(1) + expect(data?.subsections[0].label).toBeNull() + }) + + it('keeps the flat list alongside the subdivision', async () => { + getVariantCsvNamespaces.mockResolvedValue(TWO_SCORE_SETS) + const {sections, load} = useCsvNamespaces({urn: ref('urn:mavedb:00000001-a-1#1'), kind: 'variant'}) + + await load() + + const calibrations = sectionFor(sections.value, 'calibration') + expect(calibrations?.namespaces).toHaveLength(2) + expect(calibrations?.subsections.flatMap((s) => s.namespaces)).toHaveLength(2) + }) + + it('still selects everything by default across score sets', async () => { + getVariantCsvNamespaces.mockResolvedValue(TWO_SCORE_SETS) + const {selected, allSelected, load} = useCsvNamespaces({ + urn: ref('urn:mavedb:00000001-a-1#1'), + kind: 'variant' + }) + + await load() + + expect(allSelected.value).toBe(true) + expect(selected.value).toHaveLength(3) + }) +}) diff --git a/src/composables/use-csv-namespaces.ts b/src/composables/use-csv-namespaces.ts new file mode 100644 index 00000000..6ee757bf --- /dev/null +++ b/src/composables/use-csv-namespaces.ts @@ -0,0 +1,220 @@ +import axios from 'axios' +import {computed, ref, type Ref} from 'vue' + +import {getScoreSetCsvNamespaces, getVariantCsvNamespaces} from '@/api/mavedb' +import type {components} from '@/schema/openapi' + +export type AvailableCsvNamespace = components['schemas']['AvailableCsvNamespace'] +export type CsvNamespaceGroup = components['schemas']['CsvNamespaceGroup'] + +/** Section headings for a namespace picker, in the order they should be shown. */ +const GROUP_ORDER: CsvNamespaceGroup[] = ['data', 'annotation', 'calibration', 'provenance'] + +const GROUP_TITLES: Record = { + data: 'Measurements', + annotation: 'Annotations', + calibration: 'Clinical interpretation', + provenance: 'Provenance' +} + +export interface CsvNamespaceSubsection { + /** A heading for this run of namespaces, or null when the section needs no subdivision. */ + label: string | null + /** The URN behind the heading, for a tooltip or secondary text. Null when there is no heading. */ + urn: string | null + namespaces: AvailableCsvNamespace[] +} + +export interface CsvNamespaceSection { + group: CsvNamespaceGroup + title: string + /** Every namespace in the section, regardless of subdivision. */ + namespaces: AvailableCsvNamespace[] + /** + * The namespaces split by owning score set, when more than one is represented; otherwise a single + * null-labelled subsection that renders as an undivided list. A variant's calibrations can span score + * sets, and one means nothing against another's scores. + */ + subsections: CsvNamespaceSubsection[] +} + +/** + * Split a section's namespaces by owning score set, but only when more than one is represented. + * Headings use the title; the URN comes along for disambiguation, since titles can collide. + */ +function subdivideByScoreSet(namespaces: AvailableCsvNamespace[]): CsvNamespaceSubsection[] { + const owners = new Map() + for (const entry of namespaces) { + if (entry.scoreSet) owners.set(entry.scoreSet.urn, entry.scoreSet.title) + } + if (owners.size < 2) return [{label: null, urn: null, namespaces}] + + const subsections = [...owners.entries()] + .sort(([, titleA], [, titleB]) => titleA.localeCompare(titleB)) + .map(([urn, title]) => ({ + label: title, + urn, + namespaces: namespaces.filter((entry) => entry.scoreSet?.urn === urn) + })) + + // Anything without an owner still has to appear somewhere. + const unowned = namespaces.filter((entry) => !entry.scoreSet) + return unowned.length > 0 ? [...subsections, {label: null, urn: null, namespaces: unowned}] : subsections +} + +/** A query flag that is not a column group, rendered under "Options" and returned as an `extra`. */ +export interface CsvExtraOption { + label: string + value: string +} + +interface UseCsvNamespacesOptions { + /** The record whose namespaces to offer. Not watched; `load()` refetches when it sees a new URN. */ + urn: Ref + /** Which endpoint to ask. Variants widen over equivalent measurements; score sets do not. */ + kind: 'scoreSet' | 'variant' + /** Formatting flags. Which ones an endpoint accepts is the caller's business. */ + extraOptions?: Ref +} + +/** + * Load the CSV column namespaces a record has data for, ready to render as a picker. + * + * Fetched lazily — call `load()` when a dialog opens — and cached per URN. Labels and grouping come from + * the API, which alone knows calibration titles and release dates. + */ +export function useCsvNamespaces({urn, kind, extraOptions}: UseCsvNamespacesOptions) { + const namespaces = ref([]) + const loading = ref(false) + const error = ref(null) + const loadedUrn = ref(null) + const controller = ref(null) + const selected = ref([]) + const selectedExtras = ref([]) + + const formattingExtraOptions = computed(() => extraOptions?.value ?? []) + + const sections = computed(() => + GROUP_ORDER.map((group) => { + const inGroup = namespaces.value.filter((entry) => entry.group === group) + return { + group, + title: GROUP_TITLES[group], + namespaces: inGroup, + subsections: subdivideByScoreSet(inGroup) + } + }).filter((section) => section.namespaces.length > 0) + ) + + /** Whether the record has anything at all to offer. False while loading and on error. */ + const hasNamespaces = computed(() => namespaces.value.length > 0) + + /** Column groups on offer. Only namespaces count; checking an extra alone produces no file. */ + const totalColumnGroups = computed(() => namespaces.value.length) + + const selectedColumnGroups = computed(() => selected.value.length) + + const allSelected = computed( + () => totalColumnGroups.value > 0 && selectedColumnGroups.value === totalColumnGroups.value + ) + + /** Describes the current selection, so the picker never has to explain an implicit rule. */ + const selectionSummary = computed(() => { + if (selectedColumnGroups.value === 0) return 'No columns selected' + if (allSelected.value) return 'All columns selected' + return `${selectedColumnGroups.value} of ${totalColumnGroups.value} column groups selected` + }) + + /** Formatting options are left alone: "Select all" is about columns. */ + function selectAll(): void { + selected.value = namespaces.value.map((entry) => entry.namespace) + } + + /** + * Select the groups the API marks as defaults, and every refinement of them. Research-use-only and + * rangeless calibrations are offered but excluded, so checking one is a deliberate act. + */ + function selectDefaults(): void { + selected.value = namespaces.value + .filter((entry) => entry.selectedByDefault !== false) + .map((entry) => entry.namespace) + } + + /** Select everything, or clear when everything is already selected. */ + function toggleAll(): void { + if (!allSelected.value) { + selectAll() + return + } + selected.value = [] + } + + async function load(): Promise { + if (!urn.value) return + // Already have this record's list; a namespace set only changes when the record does. + if (loadedUrn.value === urn.value && namespaces.value.length > 0) return + + controller.value?.abort() + // Held locally as well: a superseded request must not clear the spinner or drop the live request's + // controller on its way out, or reset() would no longer be able to abort what is actually in flight. + const ownController = new AbortController() + controller.value = ownController + + loading.value = true + error.value = null + try { + const fetcher = kind === 'variant' ? getVariantCsvNamespaces : getScoreSetCsvNamespaces + const entries = await fetcher(urn.value, ownController.signal) + if (controller.value !== ownController) return + namespaces.value = entries + loadedUrn.value = urn.value + // Open on the common case and let the user narrow from there. Reopening for the same record + // short-circuits above, so a previous selection survives rather than being reset. + selectDefaults() + } catch (e: unknown) { + // An aborted request is a superseded one, not a failure to report. + if (axios.isCancel(e) || controller.value !== ownController) return + namespaces.value = [] + selected.value = [] + selectedExtras.value = [] + loadedUrn.value = null + error.value = 'Could not load the available download options.' + } finally { + if (controller.value === ownController) { + loading.value = false + controller.value = null + } + } + } + + function reset(): void { + controller.value?.abort() + controller.value = null + namespaces.value = [] + selected.value = [] + selectedExtras.value = [] + loadedUrn.value = null + error.value = null + loading.value = false + } + + return { + namespaces, + sections, + hasNamespaces, + loading, + error, + selected, + selectedExtras, + formattingExtraOptions, + totalColumnGroups, + selectedColumnGroups, + allSelected, + selectionSummary, + selectAll, + selectDefaults, + toggleAll, + load, + reset + } +} diff --git a/src/composables/use-score-set-downloads.test.ts b/src/composables/use-score-set-downloads.test.ts new file mode 100644 index 00000000..a077faa8 --- /dev/null +++ b/src/composables/use-score-set-downloads.test.ts @@ -0,0 +1,287 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest' +import {ref, watch, type Ref} from 'vue' + +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) +})) + +// The real one reaches for `document`; these tests run in the node environment. +vi.mock('@/lib/downloads', () => ({triggerDownload: vi.fn()})) + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const SCORE_SET = ref({urn: 'urn:mavedb:00000001-a-1'} as any) + +/** Collect every value the progress ref takes; it is back to null by the time a download resolves. */ +function recordProgress(source: Ref): {values: (number | null)[]; stop: () => void} { + const values: (number | null)[] = [] + const stop = watch(source, (value) => values.push(value), {flush: 'sync'}) + return {values, stop} +} + +beforeEach(() => { + downloadScoreSetFile.mockReset() + downloadScoreSetVariantData.mockReset() + downloadMappedVariants.mockReset() +}) + +describe('useScoreSetDownloads download indicator', () => { + it('is idle before anything is requested', () => { + const {fileDownloadInProgress, fileDownloadLabel} = useScoreSetDownloads({scoreSet: SCORE_SET}) + + expect(fileDownloadInProgress.value).toBe(false) + expect(fileDownloadLabel.value).toBeNull() + }) + + it('names the file being prepared while the request is in flight', async () => { + let release: (csv: string) => void = () => {} + downloadScoreSetFile.mockReturnValueOnce(new Promise((resolve) => (release = resolve))) + const {downloadFile, fileDownloadInProgress, fileDownloadLabel} = useScoreSetDownloads({scoreSet: SCORE_SET}) + + const pending = downloadFile('counts') + expect(fileDownloadInProgress.value).toBe(true) + expect(fileDownloadLabel.value).toBe('Counts') + + release('accession,c_0\n') + await pending + + expect(fileDownloadInProgress.value).toBe(false) + expect(fileDownloadLabel.value).toBeNull() + }) + + it('leaves a CSV indeterminate, since a gzipped body has no measurable total', async () => { + let release: (csv: string) => void = () => {} + downloadScoreSetFile.mockReturnValueOnce(new Promise((resolve) => (release = resolve))) + const {downloadFile, fileDownloadProgress} = useScoreSetDownloads({scoreSet: SCORE_SET}) + + const pending = downloadFile('scores') + expect(fileDownloadProgress.value).toBeNull() + + release('accession,score\n') + await pending + }) + + it('clears the indicator when the request fails', async () => { + // Otherwise a failed 10MB download leaves the buttons disabled behind a bar that never stops. + downloadScoreSetFile.mockRejectedValueOnce(new Error('boom')) + const {downloadFile, fileDownloadInProgress} = useScoreSetDownloads({scoreSet: SCORE_SET}) + + await expect(downloadFile('scores')).rejects.toThrow('boom') + + expect(fileDownloadInProgress.value).toBe(false) + }) + + it('ignores a second request while one is already running', async () => { + let release: (csv: string) => void = () => {} + downloadScoreSetFile.mockReturnValueOnce(new Promise((resolve) => (release = resolve))) + const {downloadFile, fileDownloadLabel} = useScoreSetDownloads({scoreSet: SCORE_SET}) + + const pending = downloadFile('scores') + await downloadFile('counts') + + // The first request still owns the indicator, and the second never reached the API. + expect(fileDownloadLabel.value).toBe('Scores') + expect(downloadScoreSetFile).toHaveBeenCalledTimes(1) + + release('accession,score\n') + await pending + }) + + it('covers the custom-columns download too', async () => { + let release: (csv: string) => void = () => {} + downloadScoreSetVariantData.mockReturnValueOnce(new Promise((resolve) => (release = resolve))) + const {downloadMultipleData, fileDownloadLabel} = useScoreSetDownloads({scoreSet: SCORE_SET}) + + const pending = downloadMultipleData({namespaces: ['scores'], extras: []}) + expect(fileDownloadLabel.value).toBe('Custom data') + + release('accession,score\n') + await pending + + 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)}) + + await downloadFile('scores') + + expect(downloadScoreSetFile).not.toHaveBeenCalled() + expect(fileDownloadInProgress.value).toBe(false) + }) +}) + +describe('useScoreSetDownloads annotation streaming shares the indicator', () => { + /** Serve an NDJSON body in the given chunks, with an optional X-Total-Count. */ + function mockStream(chunks: string[], totalCount: string | null = '4') { + const encoder = new TextEncoder() + let index = 0 + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + headers: {get: (name: string) => (name === 'X-Total-Count' ? totalCount : null)}, + body: { + getReader: () => ({ + read: async () => + index < chunks.length ? {done: false, value: encoder.encode(chunks[index++])} : {done: true} + }) + } + }) + ) + } + + beforeEach(() => { + // The stream saves its file through Blob + an anchor click rather than `triggerDownload`, so the + // node environment needs both stubbed for the completion path to run. + vi.stubGlobal('Blob', class {}) + vi.stubGlobal('URL', {createObjectURL: () => 'blob:stub', revokeObjectURL: () => {}}) + vi.stubGlobal('document', {createElement: () => ({click: () => {}})}) + }) + + it('reports a percentage, since a VA-Spec stream can count records', async () => { + mockStream(['{"a":1}\n', '{"a":2}\n'], '2') + const downloads = useScoreSetDownloads({scoreSet: SCORE_SET}) + const {values, stop} = recordProgress(downloads.fileDownloadProgress) + + const pending = downloads.streamVariantAnnotations('study-result', 'Functional Study Result') + expect(downloads.fileDownloadLabel.value).toBe('Functional Study Result') + await pending + stop() + + // One record then both, then released. + expect(values).toEqual([50, 100, null]) + }) + + it('refuses to save a truncated download', async () => { + // The bar stalling partway is the visible symptom of the server generator raising mid-stream: the 200 + // and its headers are long gone, so a short body is all the client ever sees. Saving it would hand + // the user a silently incomplete file. + mockStream(['{"a":1}\n'], '5') + const downloads = useScoreSetDownloads({scoreSet: SCORE_SET}) + + await expect(downloads.streamVariantAnnotations('functional-statement')).rejects.toThrow('received 1 of 5 records') + expect(downloads.fileDownloadInProgress.value).toBe(false) + }) + + it('tallies variants the server could not annotate, so a caller can report them', async () => { + const errorRecord = '{"variant_urn":"urn:1","annotation":null,"error":{"type":"ValueError","detail":"bad"}}\n' + mockStream([errorRecord, '{"variant_urn":"urn:2","annotation":{"type":"Stub"}}\n'], '2') + const downloads = useScoreSetDownloads({scoreSet: SCORE_SET}) + + expect(await downloads.streamVariantAnnotations('study-result')).toEqual({received: 2, errored: 1}) + }) + + it('saves a stream containing error records, since the file is complete', async () => { + // A variant the server could not annotate is reported in-band. The download is not a failure. + const errorRecord = '{"variant_urn":"urn:1","annotation":null,"error":{"type":"KeyError","detail":"score"}}\n' + mockStream([errorRecord], '1') + const downloads = useScoreSetDownloads({scoreSet: SCORE_SET}) + + await expect(downloads.streamVariantAnnotations('study-result')).resolves.toEqual({received: 1, errored: 1}) + }) + + it('counts an error record split across chunks exactly once', async () => { + // Records are scanned per chunk, so a boundary landing mid-record must not lose or double it. + mockStream(['{"variant_urn":"urn:1","annotation":null,"err', 'or":{"type":"KeyError","detail":"s"}}\n'], '1') + const downloads = useScoreSetDownloads({scoreSet: SCORE_SET}) + + expect(await downloads.streamVariantAnnotations('study-result')).toEqual({received: 1, errored: 1}) + }) + + it('does not mistake an "error" nested inside an annotation for a failed record', async () => { + // The substring test is only a prefilter; the record is parsed to confirm the key is its own. + mockStream(['{"variant_urn":"urn:1","annotation":{"notes":"see \\"error\\" handling"}}\n'], '1') + const downloads = useScoreSetDownloads({scoreSet: SCORE_SET}) + + expect(await downloads.streamVariantAnnotations('study-result')).toEqual({received: 1, errored: 0}) + }) + + it('is not skewed by a multi-byte character', async () => { + // The body is still retained as bytes — only one chunk at a time is decoded, to scan its lines. + mockStream(['{"p":"p.Trp26€"}\n', '{"p":"p.Met1?"}\n'], '2') + const downloads = useScoreSetDownloads({scoreSet: SCORE_SET}) + const {values, stop} = recordProgress(downloads.fileDownloadProgress) + + await downloads.streamVariantAnnotations('study-result') + stop() + + expect(values).toEqual([50, 100, null]) + }) + + it('does not overshoot 100% across chunk boundaries', async () => { + // `split('\n').length` counted one extra per chunk, so a record split across chunks was double + // counted and the old bar sailed past 100%. + mockStream(['{"a":1}\n{"a":', '2}\n{"a":3}\n{"a":4}\n'], '4') + const downloads = useScoreSetDownloads({scoreSet: SCORE_SET}) + const {values, stop} = recordProgress(downloads.fileDownloadProgress) + + await downloads.streamVariantAnnotations('study-result') + stop() + + expect(Math.max(...values.map((value) => value ?? 0))).toBe(100) + }) + + it('stays indeterminate when the total count header is absent', async () => { + // Previously this divided by zero and set the bar to Infinity. + mockStream(['{"a":1}\n'], null) + const downloads = useScoreSetDownloads({scoreSet: SCORE_SET}) + const {values, stop} = recordProgress(downloads.fileDownloadProgress) + + await downloads.streamVariantAnnotations('study-result') + stop() + + expect(values.every((value) => value === null)).toBe(true) + }) + + it('blocks a CSV download while a stream is running, since they share one indicator', async () => { + let releaseRead: () => void = () => {} + const encoder = new TextEncoder() + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + headers: {get: () => '1'}, + body: { + getReader: () => ({ + read: () => + new Promise((resolve) => { + releaseRead = () => resolve({done: false, value: encoder.encode('{"a":1}\n')}) + }) + }) + } + }) + ) + const {streamVariantAnnotations, downloadFile, fileDownloadLabel} = useScoreSetDownloads({scoreSet: SCORE_SET}) + + void streamVariantAnnotations('study-result', 'Functional Study Result') + await Promise.resolve() + await Promise.resolve() + await downloadFile('scores') + + expect(fileDownloadLabel.value).toBe('Functional Study Result') + expect(downloadScoreSetFile).not.toHaveBeenCalled() + releaseRead() + }) +}) diff --git a/src/composables/use-score-set-downloads.ts b/src/composables/use-score-set-downloads.ts index 44385d67..51b024f6 100644 --- a/src/composables/use-score-set-downloads.ts +++ b/src/composables/use-score-set-downloads.ts @@ -1,5 +1,7 @@ import {computed, ref, type Ref} from 'vue' +import type {CsvExtraOption} from '@/composables/use-csv-namespaces' + import {downloadScoreSetFile, downloadScoreSetVariantData, downloadMappedVariants} from '@/api/mavedb' import config from '@/config' import {triggerDownload} from '@/lib/downloads' @@ -9,57 +11,105 @@ 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. */ +export interface AnnotationStreamOutcome { + /** Records received. Equals `X-Total-Count` for a complete stream — the server emits one per variant. */ + received: number + /** + * Variants the server could not annotate. Those records are in the saved file, carrying an `error` + * object in place of an annotation. Variants with no mapping data are not counted here: a null + * annotation is an expected absence, not a failure. + */ + errored: number +} + +/** + * Whether an NDJSON line is a record the server marked as failed. + * + * The substring test is a prefilter, not the decision: `"error"` can appear anywhere inside a large + * annotation, so a candidate line is parsed to confirm the key is the record's own. Parsing only + * candidates matters — these records nest deeply, and parsing every one of a large stream is slow. + */ +function isErrorRecord(line: string): boolean { + if (!line.includes('"error"')) return false + try { + return JSON.parse(line)?.error != null + } catch { + return false + } +} + interface UseScoreSetDownloadsOptions { scoreSet: Ref - hasCounts?: Ref } -export function useScoreSetDownloads({scoreSet, hasCounts}: UseScoreSetDownloadsOptions) { +export function useScoreSetDownloads({scoreSet}: UseScoreSetDownloadsOptions) { const customDialogVisible = ref(false) - const selectedDataOptions = ref([]) - const annotatedDownloadInProgress = ref(false) - const annotatedDownloadProgress = ref(0) const streamController = ref(null) - const dataTypeOptions = computed(() => { - const options = [ - {label: 'Scores', value: 'scores'}, - {label: 'Mapped HGVS', value: 'mappedHgvs'}, - {label: 'Custom columns', value: 'includeCustomColumns'}, - {label: 'Without NA columns', value: 'dropNaColumns'} - ] - if (hasCounts?.value) { - options.splice(1, 0, {label: 'Counts', value: 'counts'}) + /** What file is currently being prepared, or null when idle. One indicator for every download here. */ + const fileDownloadLabel = ref(null) + + /** + * 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 + * percentage exists — and most of that wait is the server building the file before any byte is sent. + */ + const fileDownloadProgress = ref(null) + + const fileDownloadInProgress = computed(() => fileDownloadLabel.value !== null) + + /** Run *download* with the indicator showing, clearing it even if the request fails. */ + async function withIndicator(label: string, download: () => Promise): Promise { + if (fileDownloadLabel.value !== null) return + fileDownloadLabel.value = label + try { + return await download() + } finally { + fileDownloadLabel.value = null + fileDownloadProgress.value = null } - return options - }) + } + + /** Formatting flags. Column groups come from the csv-namespaces endpoint via MvCsvColumnDialog. */ + const extraDownloadOptions: CsvExtraOption[] = [ + {label: "Omit HGVS columns this score set doesn't use", value: 'dropUnusedHgvsColumns'} + ] async function downloadFile(type: 'scores' | 'counts') { if (!scoreSet.value) return - const data = await downloadScoreSetFile(scoreSet.value.urn, type) - triggerDownload(data, `${scoreSet.value.urn}_${type}.csv`) + await withIndicator(type === 'scores' ? 'Scores' : 'Counts', async () => { + const data = await downloadScoreSetFile(scoreSet.value!.urn, type) + triggerDownload(data, `${scoreSet.value!.urn}_${type}.csv`) + }) } - async function downloadMultipleData() { + /** + * Download the score set's variant data with the chosen column groups. Namespaces pass through + * verbatim — they are the API's own vocabulary. (This previously sent an unsupported `data_type` + * parameter, so every selection was silently ignored.) + */ + async function downloadMultipleData({namespaces, extras}: {namespaces: string[]; extras: string[]}) { if (!scoreSet.value) return const params = new URLSearchParams() - for (const opt of selectedDataOptions.value) { - if (opt === 'scores') params.append('data_type', 'scores') - else if (opt === 'counts') params.append('data_type', 'counts') - else if (opt === 'mappedHgvs') params.append('include_post_mapped_hgvs', 'true') - else if (opt === 'includeCustomColumns') params.append('include_custom_columns', 'true') - else if (opt === 'dropNaColumns') params.append('drop_na_columns', 'true') - } - if (!params.has('data_type')) params.append('data_type', 'scores') - const data = await downloadScoreSetVariantData(scoreSet.value.urn, params) - triggerDownload(data, `${scoreSet.value.urn}_custom.csv`) - customDialogVisible.value = false + for (const namespace of namespaces) params.append('namespaces', namespace) + if (extras.includes('dropUnusedHgvsColumns')) params.append('drop_unused_hgvs_columns', 'true') + await withIndicator('Custom data', async () => { + const data = await downloadScoreSetVariantData(scoreSet.value!.urn, params) + triggerDownload(data, `${scoreSet.value!.urn}_custom.csv`) + customDialogVisible.value = false + }) } async function downloadMappedVariantsFile() { if (!scoreSet.value) return - const data = await downloadMappedVariants(scoreSet.value.urn) - triggerDownload(JSON.stringify(data), `${scoreSet.value.urn}_mapped_variants.json`, 'text/json') + 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() { @@ -71,22 +121,26 @@ export function useScoreSetDownloads({scoreSet, hasCounts}: UseScoreSetDownloads function abortStream() { if (streamController.value) { streamController.value.abort() - annotatedDownloadInProgress.value = false - annotatedDownloadProgress.value = 0 + fileDownloadLabel.value = null + fileDownloadProgress.value = null } } - async function streamVariantAnnotations(annotationType: string) { - if (!scoreSet.value) return - abortStream() + /** 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)) + } + + async function streamAnnotationsInto(urn: string, annotationType: string) { streamController.value = new AbortController() try { - annotatedDownloadInProgress.value = true - const response = await fetch( - `${config.apiBaseUrl}/score-sets/${scoreSet.value.urn}/annotated-variants/${annotationType}`, - {signal: streamController.value.signal, headers: {Accept: 'application/x-ndjson'}} - ) + const response = await fetch(`${config.apiBaseUrl}/score-sets/${urn}/annotated-variants/${annotationType}`, { + signal: streamController.value.signal, + headers: {Accept: 'application/x-ndjson'} + }) if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) @@ -96,27 +150,57 @@ export function useScoreSetDownloads({scoreSet, hasCounts}: UseScoreSetDownloads const reader = response.body?.getReader() if (!reader) throw new Error('Response body is not readable') + // The body is retained as raw bytes, never as accumulated strings. Accumulating decoded chunks and + // joining them cost roughly five times the payload — JS strings are UTF-16, so the chunks alone + // doubled it, the join doubled that again, and the Blob copied the result. A large pathogenicity + // download ran the tab out of memory. Each chunk is decoded to scan its lines and then dropped, so + // only one chunk plus a partial line is ever live. + const parts: Uint8Array[] = [] const decoder = new TextDecoder() - const chunks: string[] = [] + let partialLine = '' let processedCount = 0 + let erroredCount = 0 while (true) { const {done, value} = await reader.read() - if (done) { - const blob = new Blob([chunks.join('')], {type: 'application/x-ndjson'}) - const url = URL.createObjectURL(blob) - const anchor = document.createElement('a') - anchor.href = url - anchor.download = `${scoreSet.value!.urn}_annotated_variants_${annotationType}.ndjson` - anchor.click() - URL.revokeObjectURL(url) - break + if (done) break + + parts.push(value) + + // `stream: true` carries a multi-byte character split across chunks into the next decode, and + // popping the final element keeps a record split across chunks from being counted twice. + const lines = (partialLine + decoder.decode(value, {stream: true})).split('\n') + partialLine = lines.pop() ?? '' + for (const line of lines) { + if (!line) continue + processedCount += 1 + if (isErrorRecord(line)) erroredCount += 1 } - const chunk = decoder.decode(value) - chunks.push(chunk) - processedCount += chunk.split('\n').length - annotatedDownloadProgress.value = Math.round((processedCount / totalCount) * 100) + + // Without a total there is nothing to divide by; stay indeterminate rather than report Infinity. + fileDownloadProgress.value = + totalCount > 0 ? Math.min(100, Math.round((processedCount / totalCount) * 100)) : null } + + // The server emits exactly one record per variant, so a short body means it stopped early — status + // and headers went out with the first chunk, so truncation is the only symptom a mid-stream failure + // can produce. Refuse to save a file that is quietly missing records. + if (totalCount > 0 && processedCount < totalCount) { + throw new Error( + `Download incomplete: received ${processedCount} of ${totalCount} records.` + + ' The server stopped sending partway through; check the API logs.' + ) + } + + const blob = new Blob(parts as BlobPart[], {type: 'application/x-ndjson'}) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = `${urn}_annotated_variants_${annotationType}.ndjson` + anchor.click() + URL.revokeObjectURL(url) + + return {received: processedCount, errored: erroredCount} } catch (error: unknown) { const message = error instanceof Error ? error.message : 'Unknown error' if (message !== 'The user aborted a request.') { @@ -124,20 +208,17 @@ export function useScoreSetDownloads({scoreSet, hasCounts}: UseScoreSetDownloads } } finally { streamController.value = null - annotatedDownloadInProgress.value = false - annotatedDownloadProgress.value = 0 } } return { // State customDialogVisible, - selectedDataOptions, - annotatedDownloadInProgress, - annotatedDownloadProgress, + fileDownloadInProgress, + fileDownloadLabel, + fileDownloadProgress, - // Computed - dataTypeOptions, + extraDownloadOptions, // Methods downloadFile, diff --git a/src/composables/use-variant-lookup.ts b/src/composables/use-variant-lookup.ts index f7b6bd57..194499dd 100644 --- a/src/composables/use-variant-lookup.ts +++ b/src/composables/use-variant-lookup.ts @@ -1,10 +1,10 @@ -import axios from 'axios' import {computed, ref, shallowRef, watch, type ComputedRef, type Ref} from 'vue' import { + downloadVariantCsv, getVariantAnnotation, getVariantDetail, - getHistogramVariantData, + getVariantPageScoreSetData, lookupVariantsByClingenId } from '@/api/mavedb/variants' import {useCalibrationResolution, type UseCalibrationResolutionReturn} from '@/composables/use-calibration-resolution' @@ -17,7 +17,9 @@ import { 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 type {components} from '@/schema/openapi' @@ -65,6 +67,7 @@ export interface UseVariantLookupReturn { scores: ComputedRef variantScoreRow: ComputedRef selectedVariantScore: ComputedRef + selectedVariantGnomad: ComputedRef // Calibration selectedCalibration: Ref @@ -84,6 +87,9 @@ export interface UseVariantLookupReturn { // Downloads 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 } /** @@ -115,6 +121,7 @@ export function useVariantLookup( 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) @@ -165,6 +172,7 @@ export function useVariantLookup( }) 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(() => { @@ -217,7 +225,7 @@ export function useVariantLookup( async function fetchScores(scoreSetUrn: string) { if (scoresCache.value[scoreSetUrn]) return try { - const data = await getHistogramVariantData(scoreSetUrn) + const data = await getVariantPageScoreSetData(scoreSetUrn) scoresCache.value = { ...scoresCache.value, [scoreSetUrn]: parseScoreSetVariantData(data) @@ -284,27 +292,45 @@ export function useVariantLookup( async function fetchVariantAnnotations(annotationType: string) { const activeVariant = selectedVariantDetail.value - if (!activeVariant?.urn) return + if (!activeVariant?.urn || downloadInProgressLabel.value !== null) return + downloadInProgressLabel.value = 'annotations' try { const data = await getVariantAnnotation(activeVariant.urn, annotationType) triggerDownload(JSON.stringify(data), activeVariant.urn + '_' + annotationType + '.json', 'text/json') } catch (error: unknown) { - let serverMessage = '' - if (axios.isAxiosError(error) && error.response?.data) { - const data = error.response.data - if (typeof data === 'string') serverMessage = data - else if (typeof data === 'object' && data !== null && 'detail' in data) serverMessage = String(data.detail) - else serverMessage = JSON.stringify(data) - } else { - serverMessage = error instanceof Error ? error.message : 'Unknown error.' - } options?.toast?.add({ severity: 'error', summary: 'Download failed', - detail: `Could not fetch variant annotation: ${serverMessage}`, + detail: `Could not fetch variant annotation: ${describeRequestError(error)}`, + life: 4000 + }) + } finally { + downloadInProgressLabel.value = null + } + } + + /** + * Download the selected measurement's clinical CSV — the flat counterpart to + * `fetchVariantAnnotations`. Omitting `namespaces` asks the server for its default set. + */ + async function downloadVariantCsvFile(namespaces?: string[]) { + const activeVariant = selectedVariantDetail.value + if (!activeVariant?.urn || downloadInProgressLabel.value !== null) return + + downloadInProgressLabel.value = 'variant CSV' + try { + const data = await downloadVariantCsv(activeVariant.urn, namespaces) + triggerDownload(data, `${activeVariant.urn}.csv`, 'text/csv') + } catch (error: unknown) { + options?.toast?.add({ + severity: 'error', + summary: 'Download failed', + detail: `Could not download the variant table: ${describeRequestError(error)}`, life: 4000 }) + } finally { + downloadInProgressLabel.value = null } } @@ -404,6 +430,7 @@ export function useVariantLookup( scores, variantScoreRow, selectedVariantScore, + selectedVariantGnomad, selectedCalibration, selectedCalibrationObject, calibrationResolution, @@ -414,6 +441,8 @@ export function useVariantLookup( getKeyword, geneName, uniqueAssayCount, - fetchVariantAnnotations + fetchVariantAnnotations, + downloadVariantCsvFile, + downloadInProgressLabel } } diff --git a/src/data/mavemd.ts b/src/data/mavemd.ts index d97dec84..678b1220 100644 --- a/src/data/mavemd.ts +++ b/src/data/mavemd.ts @@ -5,11 +5,14 @@ export const MAVEMD_COLLECTION_URN = 'urn:mavedb:collection-603dafbf-4a3f-4d70-a /** Available search types on the MaveMD search page, with example queries. */ export const SEARCH_TYPE_OPTIONS = [ + {code: 'any', name: 'Any', examples: ['NP_000242.1:p.Asn566Thr', 'CA10590195', 'rs900082291', '17-7676154-G-C']}, {code: 'hgvs', name: 'HGVS', examples: ['ENST00000473961.6:c.-19-2A>T', 'NP_000242.1:p.Asn566Thr']}, {code: 'clinGenAlleleId', name: 'ClinGen Allele ID', examples: ['CA10590195', 'PA2579983208']}, {code: 'dbSnpRsId', name: 'dbSNP rsID', examples: ['rs900082291', '900082291']}, {code: 'clinVarVariationId', name: 'ClinVar Variation ID', examples: ['869058']}, - {code: 'vrsDigest', name: 'VRS Digest', examples: ['ga4gh:VA.-US8Ap1kUYvW3DzeFEYrNXgk3Xk9toKy']} + {code: 'vrsDigest', name: 'VRS Digest', examples: ['ga4gh:VA.-US8Ap1kUYvW3DzeFEYrNXgk3Xk9toKy']}, + {code: 'gnomadId', name: 'gnomAD ID', examples: ['17-7676154-G-C', '17-7579472-G-C']}, + {code: 'geneSymbol', name: 'Gene symbol', examples: ['BRCA1', 'TP53']} ] /** Variant type options for guided search. */ diff --git a/src/data/search.test.ts b/src/data/search.test.ts new file mode 100644 index 00000000..f6d5618e --- /dev/null +++ b/src/data/search.test.ts @@ -0,0 +1,51 @@ +import {describe, expect, it} from 'vitest' + +import {SEARCH_COLORS, SEARCH_PLACEHOLDERS, SEARCH_TYPES} from './search' +import {SEARCH_TYPE_OPTIONS} from './mavemd' +import {detectSearchType} from '@/lib/mavemd' + +/** + * A dbSNP rsID may be written without its rs prefix, but only the ClinVar pattern matches a bare number, so this + * example resolves to ClinVar under the "Any" search type. Searching it needs the dbSNP type chosen explicitly. + */ +const AMBIGUOUS_EXAMPLES: Record = {'900082291': 'clinVarVariationId'} + +describe('SEARCH_TYPE_OPTIONS examples', () => { + it('each resolve to their own search type under "Any"', () => { + for (const {code, examples} of SEARCH_TYPE_OPTIONS) { + if (code === 'any') continue + for (const example of examples ?? []) { + expect(detectSearchType(example), example).toBe(AMBIGUOUS_EXAMPLES[example] ?? code) + } + } + }) + + it('resolve the examples "Any" itself advertises', () => { + const anyExamples = SEARCH_TYPE_OPTIONS.find((option) => option.code === 'any')?.examples ?? [] + expect(anyExamples.length).toBeGreaterThan(0) + for (const example of anyExamples) { + expect(detectSearchType(example), example).not.toBeNull() + } + }) +}) + +describe('SEARCH_TYPES', () => { + it('has a color for every type, which the homepage dropdown indexes without a fallback', () => { + for (const {value} of SEARCH_TYPES) { + expect(SEARCH_COLORS[value], value).toBeDefined() + } + }) + + it('has a placeholder for every type', () => { + for (const {value} of SEARCH_TYPES) { + expect(SEARCH_PLACEHOLDERS[value], value).toBeDefined() + } + }) + + it('uses values the MaveMD search screen recognizes, since they pass through as query params', () => { + const supported = SEARCH_TYPE_OPTIONS.map((option) => option.code) + for (const {value} of SEARCH_TYPES) { + expect(supported, value).toContain(value) + } + }) +}) diff --git a/src/data/search.ts b/src/data/search.ts index b5b266b4..9f899338 100644 --- a/src/data/search.ts +++ b/src/data/search.ts @@ -6,25 +6,34 @@ */ export const SEARCH_TYPES = [ + {value: 'any', label: 'Any'}, {value: 'hgvs', label: 'HGVS'}, {value: 'dbSnpRsId', label: 'dbSNP'}, {value: 'clinVarVariationId', label: 'ClinVar'}, {value: 'clinGenAlleleId', label: 'ClinGen'}, - {value: 'vrsDigest', label: 'VRS'} + {value: 'vrsDigest', label: 'VRS'}, + {value: 'gnomadId', label: 'gnomAD'}, + {value: 'geneSymbol', label: 'Gene'} ] export const SEARCH_COLORS: Record = { - hgvs: {accent: 'var(--color-sage)', bg: 'var(--color-sage-light)'}, + any: {accent: 'var(--color-sage)', bg: 'var(--color-sage-light)'}, + hgvs: {accent: 'var(--color-hgvs)', bg: 'var(--color-hgvs-light)', activeText: '#ffffff'}, dbSnpRsId: {accent: 'var(--color-dbsnp)', bg: 'var(--color-dbsnp-light)'}, clinVarVariationId: {accent: 'var(--color-clinvar)', bg: 'var(--color-clinvar-light)'}, clinGenAlleleId: {accent: 'var(--color-orange-cta)', bg: 'var(--color-orange-light)'}, - vrsDigest: {accent: 'var(--color-ga4gh)', bg: 'var(--color-ga4gh-light)', activeText: '#ffffff'} + vrsDigest: {accent: 'var(--color-ga4gh)', bg: 'var(--color-ga4gh-light)', activeText: '#ffffff'}, + gnomadId: {accent: 'var(--color-gnomad)', bg: 'var(--color-gnomad-light)', activeText: '#ffffff'}, + geneSymbol: {accent: 'var(--color-gene)', bg: 'var(--color-gene-light)', activeText: '#ffffff'} } export const SEARCH_PLACEHOLDERS: Record = { + any: {full: 'Search by any variant identifier or gene symbol', short: 'Search by any identifier'}, hgvs: {full: 'Search by HGVS, e.g. NM_007294.3:c.211A>G', short: 'Search by HGVS'}, dbSnpRsId: {full: 'Search by dbSNP rsID, e.g. rs28897672', short: 'Search by dbSNP rsID'}, clinVarVariationId: {full: 'Search by ClinVar Variation ID, e.g. 37610', short: 'Search by ClinVar ID'}, clinGenAlleleId: {full: 'Search by ClinGen Allele ID, e.g. CA003746', short: 'Search by ClinGen Allele ID'}, - vrsDigest: {full: 'Search by VRS ID, e.g. ga4gh:VA.n9ax-9x6gOC0OEt73VMYqCBfqfxG1XUH', short: 'Search by VRS ID'} + vrsDigest: {full: 'Search by VRS ID, e.g. ga4gh:VA.n9ax-9x6gOC0OEt73VMYqCBfqfxG1XUH', short: 'Search by VRS ID'}, + gnomadId: {full: 'Search by gnomAD ID, e.g. 17-7676154-G-C', short: 'Search by gnomAD ID'}, + geneSymbol: {full: 'Search by gene symbol, e.g. BRCA1', short: 'Search by gene symbol'} } diff --git a/src/lib/errors.test.ts b/src/lib/errors.test.ts new file mode 100644 index 00000000..e258023a --- /dev/null +++ b/src/lib/errors.test.ts @@ -0,0 +1,65 @@ +import {describe, expect, it, vi} from 'vitest' + +import {describeRequestError, getErrorResponse} from './errors' + +vi.mock('axios', () => { + const isAxiosError = (error: unknown) => Boolean((error as {isAxiosError?: boolean})?.isAxiosError) + return {default: {isAxiosError}, isAxiosError} +}) + +function axiosError(data: unknown): unknown { + return {isAxiosError: true, response: {status: 400, data}, message: 'Request failed'} +} + +describe('describeRequestError', () => { + it('prefers a string detail from the server', () => { + expect(describeRequestError(axiosError({detail: 'Score set is private.'}))).toBe('Score set is private.') + }) + + it('serializes a non-string detail rather than losing it', () => { + const detail = [{loc: ['query', 'namespaces', 0], msg: 'Value error, must be one of ...'}] + + const message = describeRequestError(axiosError({detail})) + + expect(message).toContain('namespaces') + expect(message).toContain('Value error') + }) + + it('returns a plain string body as-is', () => { + expect(describeRequestError(axiosError('Gateway timeout'))).toBe('Gateway timeout') + }) + + it('serializes a response body with no detail field', () => { + expect(describeRequestError(axiosError({error: 'nope'}))).toBe('{"error":"nope"}') + }) + + it('falls back to the exception message for non-request failures', () => { + expect(describeRequestError(new Error('Network down'))).toBe('Network down') + }) + + it('falls back to the exception message when there is no response body', () => { + expect(describeRequestError({isAxiosError: true, message: 'Request failed'})).toBe('Unknown error.') + }) + + it('always returns something renderable', () => { + expect(describeRequestError(undefined)).toBe('Unknown error.') + expect(describeRequestError('a bare string')).toBe('Unknown error.') + }) +}) + +describe('getErrorResponse', () => { + it('extracts the status and body from an Axios error', () => { + expect(getErrorResponse(axiosError({detail: 'Score set is private.'}))).toEqual({ + status: 400, + data: {detail: 'Score set is private.'} + }) + }) + + it('falls back to a 500 for non-Axios errors', () => { + expect(getErrorResponse(new Error('Network down'))).toEqual({status: 500}) + }) + + it('falls back to a 500 when the Axios error has no response', () => { + expect(getErrorResponse({isAxiosError: true, message: 'Request failed'})).toEqual({status: 500}) + }) +}) diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 00000000..db0f50e1 --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,43 @@ +import axios, {isAxiosError} from 'axios' + +/** + * Extract a message worth showing a user from a failed request. + * + * Prefers the server's own `detail`, which is where FastAPI puts the human-readable reason, and falls + * 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`. + */ +export function describeRequestError(error: unknown): string { + if (axios.isAxiosError(error) && error.response?.data) { + const data = error.response.data + if (typeof data === 'string') return data + if (typeof data === 'object' && data !== null && 'detail' in data) { + const detail = (data as {detail: unknown}).detail + // A string detail is the common case. A validation error is an array of objects; JSON is more use + // to a reader than "AxiosError: Request failed with status code 422". + return typeof detail === 'string' ? detail : JSON.stringify(detail) + } + return JSON.stringify(data) + } + return error instanceof Error ? error.message : 'Unknown error.' +} + +export interface ErrorResponse { + status: number + data?: Record +} + +/** + * Extract a normalized status and body from a caught Axios error. + * + * The counterpart to `describeRequestError`: use this when the caller branches on the status code, and + * that one when it just needs something to show the user. + */ +export function getErrorResponse(e: unknown): ErrorResponse { + if (isAxiosError(e) && e.response) { + return {status: e.response.status, data: e.response.data as Record} + } + return {status: 500} +} diff --git a/src/lib/genes.ts b/src/lib/genes.ts index 4b711a76..88d7d4aa 100644 --- a/src/lib/genes.ts +++ b/src/lib/genes.ts @@ -1,4 +1,5 @@ -import {getErrorResponse, type GeneResponse, type GeneScoreSet} from '@/api/mavedb' +import {type GeneResponse, type GeneScoreSet} from '@/api/mavedb' +import {getErrorResponse} from '@/lib/errors' export type GeneErrorState = 'not-found' | 'retryable' diff --git a/src/lib/gnomad.test.ts b/src/lib/gnomad.test.ts new file mode 100644 index 00000000..c5eb4ee7 --- /dev/null +++ b/src/lib/gnomad.test.ts @@ -0,0 +1,265 @@ +import {describe, expect, it, test} from 'vitest' +import { + CHROMOSOME_REFSEQ_IDS, + gnomadIdToHgvs, + gnomadIdToHgvsCandidates, + otherAssembly, + parseGnomadId, + formatFrequency, + gnomadFromVariantRow, + gnomadVariantUrl, + type GnomadFrequency +} from './gnomad' +import type {RawVariant} from '@/lib/variants' + +/** The GRCh38 translation of a gnomAD ID, which is the one tried first. */ +function grch38Hgvs(gnomadId: string): string | undefined { + return gnomadIdToHgvsCandidates(gnomadId)[0]?.hgvs +} + +describe('parseGnomadId', () => { + it('parses a substitution', () => { + expect(parseGnomadId('X-41334274-A-C')).toEqual({ + chromosome: 'X', + position: 41334274, + referenceAllele: 'A', + alternateAllele: 'C' + }) + }) + + it('uppercases alleles and ignores surrounding whitespace', () => { + expect(parseGnomadId(' 1-11796321-g-a ')).toEqual({ + chromosome: '1', + position: 11796321, + referenceAllele: 'G', + alternateAllele: 'A' + }) + }) + + it('normalizes the mitochondrion to a single key', () => { + expect(parseGnomadId('M-8993-T-G')?.chromosome).toBe('M') + expect(parseGnomadId('MT-8993-T-G')?.chromosome).toBe('M') + }) + + it('rejects malformed IDs', () => { + for (const id of ['X-41334274-A', 'X:41334274:A:C', 'chr1-11796321-G-A', '1-11796321-N-A', 'rs1801133', '']) { + expect(parseGnomadId(id), id).toBeNull() + } + }) + + it('rejects chromosomes that do not exist', () => { + for (const id of ['0-100-A-C', '23-100-A-C', 'Z-100-A-C']) { + expect(parseGnomadId(id), id).toBeNull() + } + }) +}) + +describe('gnomadIdToHgvsCandidates', () => { + it('translates a substitution', () => { + expect(grch38Hgvs('X-41334274-A-C')).toBe('NC_000023.11:g.41334274A>C') + }) + + it('translates an insertion', () => { + expect(grch38Hgvs('1-55516888-G-GA')).toBe('NC_000001.11:g.55516888_55516889insA') + }) + + it('translates a multi-base insertion', () => { + expect(grch38Hgvs('1-55516888-G-GATC')).toBe('NC_000001.11:g.55516888_55516889insATC') + }) + + it('translates a single-base deletion', () => { + expect(grch38Hgvs('1-55516888-GA-G')).toBe('NC_000001.11:g.55516889del') + }) + + it('translates a multi-base deletion', () => { + expect(grch38Hgvs('1-55516888-GATC-G')).toBe('NC_000001.11:g.55516889_55516891del') + }) + + it('translates a delins', () => { + expect(grch38Hgvs('1-55516888-GA-CT')).toBe('NC_000001.11:g.55516888_55516889delinsCT') + }) + + it('translates a single-base delins', () => { + expect(grch38Hgvs('1-55516888-G-CT')).toBe('NC_000001.11:g.55516888delinsCT') + }) + + it('trims a common suffix as well as a common prefix', () => { + // AGA>AA leaves only the G at 55516889 deleted. + expect(grch38Hgvs('1-55516888-AGA-AA')).toBe('NC_000001.11:g.55516889del') + // AT>T leaves only the A at 55516888 deleted. + expect(grch38Hgvs('1-55516888-AT-T')).toBe('NC_000001.11:g.55516888del') + }) + + it('returns GRCh38 first, then GRCh37, each labelled with its assembly', () => { + expect(gnomadIdToHgvsCandidates('17-7676154-G-C')).toEqual([ + {assembly: 'grch38', hgvs: 'NC_000017.11:g.7676154G>C'}, + {assembly: 'grch37', hgvs: 'NC_000017.10:g.7676154G>C'} + ]) + }) + + it('uses the shared rCRS accession for the mitochondrion in both assemblies', () => { + expect(gnomadIdToHgvsCandidates('M-8993-T-G')).toEqual([ + {assembly: 'grch38', hgvs: 'NC_012920.1:g.8993T>G'}, + {assembly: 'grch37', hgvs: 'NC_012920.1:g.8993T>G'} + ]) + }) + + it('returns no candidates for an unparseable ID', () => { + expect(gnomadIdToHgvsCandidates('not-an-id')).toEqual([]) + }) + + it('returns no candidates when the alleles describe no change', () => { + expect(gnomadIdToHgvsCandidates('1-55516888-A-A')).toEqual([]) + expect(gnomadIdToHgvsCandidates('1-55516888-AT-AT')).toEqual([]) + }) +}) + +describe('gnomadIdToHgvs', () => { + it('reads an ID under the requested assembly', () => { + expect(gnomadIdToHgvs('17-7676154-G-C', 'grch38')).toBe('NC_000017.11:g.7676154G>C') + expect(gnomadIdToHgvs('17-7676154-G-C', 'grch37')).toBe('NC_000017.10:g.7676154G>C') + }) + + it('returns null for an ID it cannot translate', () => { + expect(gnomadIdToHgvs('not-an-id', 'grch38')).toBeNull() + }) +}) + +describe('otherAssembly', () => { + it('pairs the two assemblies', () => { + expect(otherAssembly('grch38')).toBe('grch37') + expect(otherAssembly('grch37')).toBe('grch38') + }) +}) + +describe('CHROMOSOME_REFSEQ_IDS', () => { + it('covers all 24 chromosomes plus the mitochondrion', () => { + expect(Object.keys(CHROMOSOME_REFSEQ_IDS)).toHaveLength(25) + }) + + it('assigns a distinct accession to every chromosome within an assembly', () => { + for (const assembly of ['grch38', 'grch37'] as const) { + const accessions = Object.values(CHROMOSOME_REFSEQ_IDS).map((ids) => ids[assembly]) + expect(new Set(accessions).size, assembly).toBe(accessions.length) + } + }) + + it('uses different accessions per assembly for every chromosome but the mitochondrion', () => { + for (const [chromosome, ids] of Object.entries(CHROMOSOME_REFSEQ_IDS)) { + if (chromosome === 'M') { + expect(ids.grch38).toBe(ids.grch37) + } else { + expect(ids.grch38, chromosome).not.toBe(ids.grch37) + } + } + }) +}) + +/** 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('—') + expect(formatFrequency(undefined)).toBe('—') + }) + + test('very rare (< 1e-4) → scientific notation, else 3 significant figures', () => { + expect(formatFrequency(0.00001)).toBe('1.00e-5') + expect(formatFrequency(0.0123456)).toBe('0.0123') + expect(formatFrequency(0)).toBe('0.00e+0') + }) +}) + +describe('gnomadVariantUrl — dataset matches the record version', () => { + test.each([ + ['v4.1', 'gnomad_r4'], + ['v3.1.2', 'gnomad_r3'], + ['v2.1.1', 'gnomad_r2_1'], + // Bare majors, as older records and fixtures carry them. + ['4', 'gnomad_r4'], + ['3', 'gnomad_r3'], + ['2', 'gnomad_r2_1'] + ])('version %s → %s', (dbVersion, dataset) => { + const url = gnomadVariantUrl({dbIdentifier: '1-55051215-G-A', dbVersion}) + expect(url).toContain(`dataset=${dataset}`) + expect(url).toContain('/variant/1-55051215-G-A') + }) + + test('an unrecognisable version falls back to the current dataset', () => { + expect(gnomadVariantUrl({dbIdentifier: 'x', dbVersion: 'unknown'})).toContain('dataset=gnomad_r4') + }) +}) diff --git a/src/lib/gnomad.ts b/src/lib/gnomad.ts new file mode 100644 index 00000000..e3283600 --- /dev/null +++ b/src/lib/gnomad.ts @@ -0,0 +1,257 @@ +/** + * @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. + * + * 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. + */ +import {gnomadIdRegex} from './mavemd' +import type {components} from '@/schema/openapi' +import type {RawVariant} from '@/lib/variants' + +/** + * One gnomAD frequency record, as consumed by the display components. + * + * Picked from the generated schema rather than restated, so renaming or retyping a field on the API's + * model breaks compilation here. + * + * 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. + */ +export type GnomadFrequency = Pick< + components['schemas']['GnomADVariantWithMappedVariants'], + 'alleleFrequency' | 'alleleCount' | 'alleleNumber' | 'faf95Max' | 'faf95MaxAncestry' | 'dbIdentifier' | 'dbVersion' +> + +/** A CSV cell from the `gnomad` namespace: a number, the `'NA'` sentinel, or absent. */ +type GnomadCell = number | string | null | undefined + +/** + * Translation of gnomAD variant IDs (e.g. 1-11796321-G-A) into genomic HGVS. + * + * A gnomAD ID is a VCF-style chromosome-position-reference-alternate tuple that doesn't name the reference genome its + * coordinates belong to, so it is translated against each supported assembly in turn and the ClinGen Allele Registry + * decides which one the coordinates actually match. + */ + +/** Assemblies tried, in order, when resolving a gnomAD ID. */ +export const GNOMAD_ASSEMBLY_SEARCH_ORDER = ['grch38', 'grch37'] as const + +export type GenomeAssembly = (typeof GNOMAD_ASSEMBLY_SEARCH_ORDER)[number] + +/** Display names for the assemblies, for use in user-facing text. */ +export const GENOME_ASSEMBLY_NAMES: Record = { + grch38: 'GRCh38', + grch37: 'GRCh37' +} + +/** The assembly to offer as an alternative interpretation of the same gnomAD ID. */ +export function otherAssembly(assembly: GenomeAssembly): GenomeAssembly { + return assembly === 'grch38' ? 'grch37' : 'grch38' +} + +/** + * RefSeq chromosome accessions per assembly, keyed by gnomAD chromosome name. + * + * These are GRCh37 accessions rather than UCSC hg19 ones, which is a distinction that matters only for the + * mitochondrion: GRCh37 and GRCh38 both use the revised Cambridge Reference Sequence, so it shares one accession here, + * whereas UCSC hg19 uses the older NC_001807 sequence. gnomAD's mitochondrial calls are GRCh38/rCRS in any case. + */ +export const CHROMOSOME_REFSEQ_IDS: Record> = { + '1': {grch38: 'NC_000001.11', grch37: 'NC_000001.10'}, + '2': {grch38: 'NC_000002.12', grch37: 'NC_000002.11'}, + '3': {grch38: 'NC_000003.12', grch37: 'NC_000003.11'}, + '4': {grch38: 'NC_000004.12', grch37: 'NC_000004.11'}, + '5': {grch38: 'NC_000005.10', grch37: 'NC_000005.9'}, + '6': {grch38: 'NC_000006.12', grch37: 'NC_000006.11'}, + '7': {grch38: 'NC_000007.14', grch37: 'NC_000007.13'}, + '8': {grch38: 'NC_000008.11', grch37: 'NC_000008.10'}, + '9': {grch38: 'NC_000009.12', grch37: 'NC_000009.11'}, + '10': {grch38: 'NC_000010.11', grch37: 'NC_000010.10'}, + '11': {grch38: 'NC_000011.10', grch37: 'NC_000011.9'}, + '12': {grch38: 'NC_000012.12', grch37: 'NC_000012.11'}, + '13': {grch38: 'NC_000013.11', grch37: 'NC_000013.10'}, + '14': {grch38: 'NC_000014.9', grch37: 'NC_000014.8'}, + '15': {grch38: 'NC_000015.10', grch37: 'NC_000015.9'}, + '16': {grch38: 'NC_000016.10', grch37: 'NC_000016.9'}, + '17': {grch38: 'NC_000017.11', grch37: 'NC_000017.10'}, + '18': {grch38: 'NC_000018.10', grch37: 'NC_000018.9'}, + '19': {grch38: 'NC_000019.10', grch37: 'NC_000019.9'}, + '20': {grch38: 'NC_000020.11', grch37: 'NC_000020.10'}, + '21': {grch38: 'NC_000021.9', grch37: 'NC_000021.8'}, + '22': {grch38: 'NC_000022.11', grch37: 'NC_000022.10'}, + X: {grch38: 'NC_000023.11', grch37: 'NC_000023.10'}, + Y: {grch38: 'NC_000024.10', grch37: 'NC_000024.9'}, + M: {grch38: 'NC_012920.1', grch37: 'NC_012920.1'} +} + +/** A gnomAD variant ID parsed into its parts, with the chromosome normalized to a {@link CHROMOSOME_REFSEQ_IDS} key. */ +export interface GnomadVariant { + chromosome: string + position: number + referenceAllele: string + alternateAllele: string +} + +/** Parse a gnomAD variant ID. Returns null if it is malformed or names a chromosome we have no accessions for. */ +export function parseGnomadId(gnomadId: string): GnomadVariant | null { + const match = gnomadIdRegex.exec(gnomadId.trim()) + if (!match) { + return null + } + const [, chromosome, position, referenceAllele, alternateAllele] = match + // gnomAD writes the mitochondrion as either M or MT. + const normalizedChromosome = chromosome.toUpperCase() === 'MT' ? 'M' : chromosome.toUpperCase() + if (!(normalizedChromosome in CHROMOSOME_REFSEQ_IDS)) { + return null + } + return { + chromosome: normalizedChromosome, + position: parseInt(position), + referenceAllele: referenceAllele.toUpperCase(), + alternateAllele: alternateAllele.toUpperCase() + } +} + +/** + * Build the HGVS description (the part after the colon) for a VCF-style change at a genomic position. + * + * gnomAD anchors indels on the base preceding the change, so the alleles are first reduced to a minimal + * representation: the common prefix is trimmed before any common suffix, which keeps the resulting coordinates as far + * 3' as the input allows. The registry applies full HGVS 3' normalization on its end. + * + * @returns The description, or null if the reference and alternate alleles describe no change. + */ +function describeGenomicChange(position: number, referenceAllele: string, alternateAllele: string): string | null { + let reference = referenceAllele + let alternate = alternateAllele + let start = position + + let prefixLength = 0 + while ( + prefixLength < reference.length && + prefixLength < alternate.length && + reference[prefixLength] === alternate[prefixLength] + ) { + prefixLength++ + } + reference = reference.slice(prefixLength) + alternate = alternate.slice(prefixLength) + start += prefixLength + + while ( + reference.length > 0 && + alternate.length > 0 && + reference[reference.length - 1] === alternate[alternate.length - 1] + ) { + reference = reference.slice(0, -1) + alternate = alternate.slice(0, -1) + } + + const end = start + reference.length - 1 + + if (reference.length === 0 && alternate.length === 0) { + return null + } + if (reference.length === 0) { + return `g.${start - 1}_${start}ins${alternate}` + } + if (alternate.length === 0) { + return reference.length === 1 ? `g.${start}del` : `g.${start}_${end}del` + } + if (reference.length === 1 && alternate.length === 1) { + return `g.${start}${reference}>${alternate}` + } + return reference.length === 1 ? `g.${start}delins${alternate}` : `g.${start}_${end}delins${alternate}` +} + +/** An HGVS reading of a gnomAD ID, valid only if the ID's coordinates belong to `assembly`. */ +export interface GnomadHgvsCandidate { + assembly: GenomeAssembly + hgvs: string +} + +/** + * Translate a gnomAD variant ID into genomic HGVS strings, one per supported assembly, in the order they should be + * tried. Returns an empty array if the ID cannot be translated. + */ +export function gnomadIdToHgvsCandidates(gnomadId: string): GnomadHgvsCandidate[] { + const variant = parseGnomadId(gnomadId) + if (!variant) { + return [] + } + const description = describeGenomicChange(variant.position, variant.referenceAllele, variant.alternateAllele) + if (!description) { + return [] + } + return GNOMAD_ASSEMBLY_SEARCH_ORDER.map((assembly) => ({ + assembly, + hgvs: `${CHROMOSOME_REFSEQ_IDS[variant.chromosome][assembly]}:${description}` + })) +} + +/** The HGVS reading of a gnomAD ID under one assembly, or null if the ID cannot be translated. */ +export function gnomadIdToHgvs(gnomadId: string, assembly: GenomeAssembly): string | null { + 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. + const major = parseInt(gnomad.dbVersion.replace(/^\D+/, ''), 10) + const dataset = major === 3 ? 'gnomad_r3' : major === 2 ? 'gnomad_r2_1' : 'gnomad_r4' + return `https://gnomad.broadinstitute.org/variant/${encodeURIComponent(gnomad.dbIdentifier)}?dataset=${dataset}` +} + +/** A frequency (e.g. gnomAD AF): scientific notation for the very rare, else 3 significant figures. */ +export function formatFrequency(value: number | null | undefined): string { + if (value == null) return '—' + return value < 0.0001 ? value.toExponential(2) : value.toPrecision(3) +} diff --git a/src/lib/mavemd.test.ts b/src/lib/mavemd.test.ts new file mode 100644 index 00000000..a49fe4df --- /dev/null +++ b/src/lib/mavemd.test.ts @@ -0,0 +1,119 @@ +import {describe, expect, it} from 'vitest' + +import {detectSearchType, geneSymbolSearchTarget, gnomadIdRegex} from './mavemd' + +describe('detectSearchType', () => { + it('recognizes each supported identifier', () => { + const expected: [string, string][] = [ + ['ga4gh:VA.-US8Ap1kUYvW3DzeFEYrNXgk3Xk9toKy', 'vrsDigest'], + ['CA10590195', 'clinGenAlleleId'], + ['PA2579983208', 'clinGenAlleleId'], + ['rs900082291', 'dbSnpRsId'], + ['17-7676154-G-C', 'gnomadId'], + ['1-55516888-G-GA', 'gnomadId'], + ['ENST00000473961.6:c.-19-2A>T', 'hgvs'], + ['NP_000242.1:p.Asn566Thr', 'hgvs'], + ['NM_007294.3(BRCA1):c.211A>G', 'hgvs'], + ['869058', 'clinVarVariationId'], + ['BRCA1', 'geneSymbol'], + ['TP53', 'geneSymbol'], + ['MYC', 'geneSymbol'], + ['HLA-A', 'geneSymbol'], + ['MT-CO1', 'geneSymbol'], + ['C1orf100', 'geneSymbol'], + ['IGH@', 'geneSymbol'] + ] + for (const [searchString, searchType] of expected) { + expect(detectSearchType(searchString), searchString).toBe(searchType) + } + }) + + it('ignores surrounding whitespace', () => { + expect(detectSearchType(' CA10590195 ')).toBe('clinGenAlleleId') + }) + + it('prefers a VRS digest over HGVS, which its colon would otherwise match', () => { + expect(detectSearchType('ga4gh:VA.-US8Ap1kUYvW3DzeFEYrNXgk3Xk9toKy')).toBe('vrsDigest') + }) + + it('treats a bare number as a ClinVar Variation ID', () => { + // dbSNP rsIDs are also accepted without their rs prefix, but only the ClinVar pattern matches a bare number, so + // that is what an unprefixed number resolves to. + expect(detectSearchType('869058')).toBe('clinVarVariationId') + expect(detectSearchType('900082291')).toBe('clinVarVariationId') + }) + + it('detects a gene symbol case-insensitively, since the search uppercases it', () => { + expect(detectSearchType('brca1')).toBe('geneSymbol') + expect(detectSearchType(' Tp53 ')).toBe('geneSymbol') + }) + + it('prefers the variant identifier where a gene symbol collides with one', () => { + // CA1-CA14 (carbonic anhydrases) and RS1 (retinoschisin 1) are real gene symbols that also satisfy the ClinGen + // and dbSNP patterns. The variant identifier wins, so reaching those gene pages needs the type chosen explicitly. + expect(detectSearchType('CA9')).toBe('clinGenAlleleId') + expect(detectSearchType('RS1')).toBe('dbSnpRsId') + }) + + it('reads a hyphenated string as a gene symbol, which it cannot be told apart from', () => { + // A mistyped ClinGen ID has the same shape as HLA-A or MT-CO1, so it resolves to a gene page rather than an error. + expect(detectSearchType('CA-10590195')).toBe('geneSymbol') + }) + + it('returns null for a string resembling no supported identifier', () => { + for (const searchString of ['', ' ', 'not an identifier', 'BRCA1/2', '???']) { + expect(detectSearchType(searchString), JSON.stringify(searchString)).toBeNull() + } + }) +}) + +describe('geneSymbolSearchTarget', () => { + it('uppercases the symbol when the gene symbol type is chosen', () => { + expect(geneSymbolSearchTarget('brca1', 'geneSymbol')).toBe('BRCA1') + expect(geneSymbolSearchTarget(' tp53 ', 'geneSymbol')).toBe('TP53') + }) + + it('detects a gene symbol under the "Any" type', () => { + expect(geneSymbolSearchTarget('brca1', 'any')).toBe('BRCA1') + }) + + it('returns null when the search is for something else', () => { + expect(geneSymbolSearchTarget('CA10590195', 'any')).toBeNull() + expect(geneSymbolSearchTarget('17-7676154-G-C', 'any')).toBeNull() + expect(geneSymbolSearchTarget('BRCA1', 'hgvs')).toBeNull() + expect(geneSymbolSearchTarget('', 'geneSymbol')).toBeNull() + expect(geneSymbolSearchTarget(' ', 'any')).toBeNull() + }) + + it('returns a malformed symbol for the caller to reject, when the type was chosen explicitly', () => { + // Only the "Any" path has already applied geneSymbolRegex, so this is what callers must still validate. + expect(geneSymbolSearchTarget('BRCA1/2', 'geneSymbol')).toBe('BRCA1/2') + expect(geneSymbolSearchTarget('BRCA1/2', 'any')).toBeNull() + }) +}) + +/** + * gnomad.test.ts already drives this pattern through parseGnomadId, covering alleles, indels, case and the malformed + * shapes. What it cannot cover is the chromosome alternation: parseGnomadId also gates on CHROMOSOME_REFSEQ_IDS, so it + * rejects an impossible chromosome whatever the pattern does. detectSearchType applies the pattern directly, with no + * such gate, so these tests exist for that path. + */ +describe('gnomadIdRegex', () => { + it('accepts chromosomes at the edges of the range', () => { + for (const id of ['1-11796321-G-A', '22-42126611-C-T', 'X-41334274-A-C', 'Y-2787175-C-T', 'MT-8993-T-G']) { + expect(gnomadIdRegex.test(id), id).toBe(true) + } + }) + + it('rejects chromosomes outside it', () => { + for (const id of ['0-11796321-G-A', '23-11796321-G-A', '30-11796321-G-A', 'Z-11796321-G-A']) { + expect(gnomadIdRegex.test(id), id).toBe(false) + } + }) + + it('anchors the whole string, rejecting a trailing field or an empty position', () => { + for (const id of ['1-11796321-G-A-T', '1--G-A']) { + expect(gnomadIdRegex.test(id), id).toBe(false) + } + }) +}) diff --git a/src/lib/mavemd.ts b/src/lib/mavemd.ts index f945e25c..dc9c7e66 100644 --- a/src/lib/mavemd.ts +++ b/src/lib/mavemd.ts @@ -1,5 +1,6 @@ import type {ClinGenAllele, ClinGenGenomicAllele, ClinGenTranscriptAllele} from '@/api/clingen' import type {components} from '@/schema/openapi' +import {hgvsSearchStringRegex} from './mave-hgvs' type VariantMeasurement = components['schemas']['VariantEffectMeasurementWithShortScoreSet'] @@ -33,6 +34,67 @@ export const clinVarVariationIdRegex = /^[0-9]+$/m */ export const rsIdRegex = /^rs[0-9]+$/im +/** + * Regular expression for valid gnomAD variant IDs that can be used in ClinGen searches. + * + * gnomAD writes these as chromosome-position-reference-alternate (e.g. 1-11796321-G-A). The capture groups are those + * four parts, in that order, which parseGnomadId relies on to translate an ID into HGVS. + */ +export const gnomadIdRegex = /^(1[0-9]|2[0-2]|[1-9]|X|Y|MT?)-([0-9]+)-([ACGT]+)-([ACGT]+)$/i + +/** + * Regular expression for HGNC gene symbols. + * + * A symbol begins with a letter and may carry digits, hyphenated parts (HLA-A, MT-CO1) and a trailing @ for cluster + * symbols (IGH@). This is by far the most permissive of the identifier patterns — any bare word satisfies it — so it + * is only ever applied once every more specific form has been ruled out. + */ +export const geneSymbolRegex = /^[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*@?$/i + +/** + * Identifier patterns in the order they are tried when detecting what a search string is. + * + * Order matters wherever the patterns overlap. A VRS digest also satisfies the deliberately loose HGVS pattern, since + * that only asks for an identifier, a colon and a description, so it has to be recognized first. A bare number is a + * ClinVar Variation ID only once the more specific forms have been ruled out, and a gene symbol — which any bare word + * resembles — only once everything else has been. + */ +const SEARCH_TYPE_PATTERNS: [string, RegExp][] = [ + ['vrsDigest', vrsDigestRegex], + ['clinGenAlleleId', clinGenAlleleIdRegex], + ['dbSnpRsId', rsIdRegex], + ['gnomadId', gnomadIdRegex], + ['hgvs', hgvsSearchStringRegex], + ['clinVarVariationId', clinVarVariationIdRegex], + ['geneSymbol', geneSymbolRegex] +] + +/** + * Work out which kind of identifier a search string is, for the "Any" search type. + * + * @returns The matching search type code, or null if the string resembles no supported identifier. + */ +export function detectSearchType(searchString: string): string | null { + const trimmedSearchString = searchString.trim() + return SEARCH_TYPE_PATTERNS.find(([, pattern]) => pattern.test(trimmedSearchString))?.[0] ?? null +} + +/** + * The gene symbol a search is for, standardized to uppercase, or null when the search isn't for a gene symbol. + * + * Uppercase is how gene pages are addressed everywhere else. A symbol arrived at through the "Any" type has already + * satisfied {@link geneSymbolRegex}; one from an explicitly chosen gene symbol search has not, so callers that care + * about malformed input should still validate what they get back. + */ +export function geneSymbolSearchTarget(searchText: string, searchType: string | null): string | null { + const trimmedSearchText = searchText.trim() + if (!trimmedSearchText) { + return null + } + const resolvedSearchType = searchType === 'any' ? detectSearchType(trimmedSearchText) : searchType + return resolvedSearchType === 'geneSymbol' ? trimmedSearchText.toUpperCase() : null +} + /** A single MANE coordinate extracted from a ClinGen transcript allele. */ export interface ManeCoordinate { sequenceType: string diff --git a/src/lib/orcid.ts b/src/lib/orcid.ts index 615ac72c..15405599 100644 --- a/src/lib/orcid.ts +++ b/src/lib/orcid.ts @@ -33,7 +33,7 @@ import {v4 as uuidv4} from 'uuid' import {computed, ref, Ref} from 'vue' import config from '../config' -import {getErrorResponse} from '@/api/mavedb' +import {getErrorResponse} from '@/lib/errors' export interface OidcUserProfileBase { auth_time: number diff --git a/src/lib/variants.ts b/src/lib/variants.ts index 1046fd77..c6c89da2 100644 --- a/src/lib/variants.ts +++ b/src/lib/variants.ts @@ -45,7 +45,7 @@ export interface RawVariant { mavedb?: { post_mapped_hgvs_c?: string post_mapped_hgvs_p?: string - post_mapped_vrs_digest?: string + post_mapped_vrs_id?: string } vep?: { vep_functional_consequence?: string @@ -53,6 +53,17 @@ export interface RawVariant { 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 diff --git a/src/router/index.ts b/src/router/index.ts index 9fbdda9f..46ab4b41 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -250,7 +250,28 @@ const routes: RouteRecordRaw[] = [ const router = createRouter({ history: createWebHistory(), - routes + routes, + /** + * Start a new page at the top, the way a full page load would. + * + * The history API never scrolls on its own, so without this a route change leaves the window wherever the previous + * page left it — arriving part way down a page the user has not seen before. + */ + scrollBehavior(to, from, savedPosition) { + // Going back or forward returns to where that entry was left. + if (savedPosition) { + return savedPosition + } + if (to.hash) { + return {el: to.hash} + } + // Screens that mirror their state into the query string, such as the search screens, navigate on every keystroke + // of state change. Those are not new pages, so leave the scroll position alone. + if (to.path === from.path) { + return false + } + return {top: 0} + } }) export default router diff --git a/src/schema/openapi.d.ts b/src/schema/openapi.d.ts index d07f1dbc..a745ac0e 100644 --- a/src/schema/openapi.d.ts +++ b/src/schema/openapi.d.ts @@ -747,6 +747,32 @@ export interface paths { */ delete: operations["delete_score_set_api_v1_score_sets__urn__delete"]; }; + "/api/v1/score-sets/{urn}/csv-namespaces": { + /** + * List the CSV column namespaces this score set has data for + * @description List the CSV column namespaces this score set has data for, labeled and grouped for a picker. + * + * Each entry's `namespace` is a value accepted by the `namespaces` parameter of the CSV endpoints. + * Deliberately a separate request rather than a field on the score set: it costs several queries and is + * only needed when a user opens a download dialog, so it should not sit on the score-set page's + * critical path. + * + * Parameters + * __________ + * urn : str + * The URN of the score set to inspect. + * db : Session + * The database session to use. + * user_data : Optional[UserData] + * The user data of the current user. If None, no user-specific permissions are checked. + * + * Returns + * _______ + * list[AvailableCsvNamespace] + * The namespaces with data, each with a human-readable label and group. + */ + get: operations["get_score_set_csv_namespaces_api_v1_score_sets__urn__csv_namespaces_get"]; + }; "/api/v1/score-sets/{urn}/variants/data": { /** * Get score set variant data in CSV format @@ -765,11 +791,19 @@ export interface paths { * The maximum number of variants to return. If None, returns all variants. * namespaces: List[str] * The namespaces of all columns except for accession, hgvs_nt, hgvs_pro, and hgvs_splice. - * Supported values: "scores", "counts", "vep", "gnomad", "clingen", and ClinVar-versioned - * namespaces of the form "clinvar.YEAR_MONTH" (e.g. "clinvar.2024_01" for January 2024). - * Multiple ClinVar namespaces with different YEAR_MONTH values may be requested simultaneously. + * Supported values: "scores" (the required score column), "scores_custom" (the investigator's + * remaining score columns, emitted under the "scores" prefix), "counts", "mavedb", "vep", "gnomad", + * "clingen", "score_set", and ClinVar- and calibration-parameterized namespaces. Multiple ClinVar + * and calibration namespaces may be requested simultaneously. + * drop_unused_hgvs_columns : bool, optional + * Whether to omit the HGVS coordinate columns this score set does not use, e.g. hgvs_nt for a + * protein-only score set. Defaults to False. * drop_na_columns : bool, optional - * Whether to drop columns that contain only NA values. Defaults to False. + * Deprecated spelling of drop_unused_hgvs_columns, accepted for one release. + * include_post_mapped_hgvs : bool, optional + * Deprecated: equivalent to requesting the "mavedb" namespace. Accepted for one release. + * include_custom_columns : bool, optional + * Deprecated: equivalent to requesting the "scores_custom" namespace. Accepted for one release. * db : Session * The database session to use. * user_data : Optional[UserData] @@ -1328,6 +1362,62 @@ export interface paths { */ get: operations["get_variant_api_v1_variants__urn__get"]; }; + "/api/v1/variants/{urn}/csv-namespaces": { + /** + * List the CSV column namespaces this variant has data for + * @description List the CSV column namespaces this variant has data for, labeled and grouped for a picker. + * + * Widens over the variant's equivalent measurements the same way the CSV does, so a calibration + * belonging to another score set that also measured this allele is offered here too. + * + * Parameters + * __________ + * urn : str + * The URN of the variant to inspect. + * db : Session + * The database session to use. + * user_data : Optional[UserData] + * The user data of the current user. If None, no user-specific permissions are checked. + * + * Returns + * _______ + * list[AvailableCsvNamespace] + * The namespaces with data, each with a human-readable label and group. + */ + get: operations["get_variant_csv_namespaces_api_v1_variants__urn__csv_namespaces_get"]; + }; + "/api/v1/variants/{urn}/csv": { + /** + * Get variant data in CSV format + * @description Return tabular data for a single variant, identified by URN, in CSV format. + * + * Where the variant-level annotation endpoints return nested VA-Spec objects, this flattens the same + * interpretation into columns a clinical information system can consume: ACMG criteria, evidence + * strengths, and evidence outcome codes alongside the measurement they were derived from. + * + * A row is emitted for every current measurement of the variant's ClinGen allele, so a variant assayed + * in several score sets yields several rows. The requested variant is always first. + * + * Parameters + * __________ + * urn : str + * The URN of the variant to fetch. + * namespaces : Optional[List[str]] + * The groups of columns to include. When omitted, the response includes the fixed groups plus one + * namespace per calibration eligible to annotate these measurements and the most recent ClinVar + * release covering them. + * db : Session + * The database session to use. + * user_data : Optional[UserData] + * The user data of the current user. If None, no user-specific permissions are checked. + * + * Returns + * _______ + * Any + * StreamingResponse containing the CSV data. + */ + get: operations["get_variant_csv_data_api_v1_variants__urn__csv_get"]; + }; "/api/v1/alphafold-files/version": { /** * Proxy Alphafold Index @@ -1628,6 +1718,28 @@ export interface components { /** Version */ version: string; }; + /** + * AvailableCsvNamespace + * @description One CSV column namespace a record has data for, ready to be offered as a choice. + * + * Labels are served rather than derived client-side: only the server knows a calibration's title or a + * ClinVar release date. + */ + AvailableCsvNamespace: { + /** Recordtype */ + recordType?: string; + /** Namespace */ + namespace: string; + /** Label */ + label: string; + group: components["schemas"]["CsvNamespaceGroup"]; + scoreSet?: components["schemas"]["ShorterScoreSet"] | null; + /** + * Selectedbydefault + * @default true + */ + selectedByDefault?: boolean; + }; /** Body_create_score_calibration_route_api_v1_score_calibrations__post */ Body_create_score_calibration_route_api_v1_score_calibrations__post: { /** @@ -2391,6 +2503,12 @@ export interface components { */ copies: components["schemas"]["Range"] | number; }; + /** + * CsvNamespaceGroup + * @description Presentational grouping, so a client can section a namespace picker. + * @enum {string} + */ + CsvNamespaceGroup: "data" | "annotation" | "calibration" | "provenance"; /** * CurrentUser * @description User view model for information about the current user. @@ -5132,10 +5250,15 @@ export interface components { /** Uniprotidfrommappedmetadata */ uniprotIdFromMappedMetadata?: string | null; }; - /** ShorterScoreSet */ + /** + * ShorterScoreSet + * @description A score set's identity: enough to name it in a UI without rooting the display on its URN. + */ ShorterScoreSet: { /** Urn */ urn: string; + /** Title */ + title: string; /** Recordtype */ recordType?: string; }; @@ -10128,6 +10251,69 @@ export interface operations { }; }; }; + /** + * List the CSV column namespaces this score set has data for + * @description List the CSV column namespaces this score set has data for, labeled and grouped for a picker. + * + * Each entry's `namespace` is a value accepted by the `namespaces` parameter of the CSV endpoints. + * Deliberately a separate request rather than a field on the score set: it costs several queries and is + * only needed when a user opens a download dialog, so it should not sit on the score-set page's + * critical path. + * + * Parameters + * __________ + * urn : str + * The URN of the score set to inspect. + * db : Session + * The database session to use. + * user_data : Optional[UserData] + * The user data of the current user. If None, no user-specific permissions are checked. + * + * Returns + * _______ + * list[AvailableCsvNamespace] + * The namespaces with data, each with a human-readable label and group. + */ + get_score_set_csv_namespaces_api_v1_score_sets__urn__csv_namespaces_get: { + parameters: { + header?: { + "x-active-roles"?: string | null; + }; + path: { + urn: string; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["AvailableCsvNamespace"][]; + }; + }; + /** @description Authentication required. */ + 401: { + content: never; + }; + /** @description Forbidden. Insufficient permissions. */ + 403: { + content: never; + }; + /** @description Resource not found. */ + 404: { + content: never; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + /** @description Internal server error. */ + 500: { + content: never; + }; + }; + }; /** * Get score set variant data in CSV format * @description Return tabular variant data from a score set, identified by URN, in CSV format. @@ -10145,11 +10331,19 @@ export interface operations { * The maximum number of variants to return. If None, returns all variants. * namespaces: List[str] * The namespaces of all columns except for accession, hgvs_nt, hgvs_pro, and hgvs_splice. - * Supported values: "scores", "counts", "vep", "gnomad", "clingen", and ClinVar-versioned - * namespaces of the form "clinvar.YEAR_MONTH" (e.g. "clinvar.2024_01" for January 2024). - * Multiple ClinVar namespaces with different YEAR_MONTH values may be requested simultaneously. + * Supported values: "scores" (the required score column), "scores_custom" (the investigator's + * remaining score columns, emitted under the "scores" prefix), "counts", "mavedb", "vep", "gnomad", + * "clingen", "score_set", and ClinVar- and calibration-parameterized namespaces. Multiple ClinVar + * and calibration namespaces may be requested simultaneously. + * drop_unused_hgvs_columns : bool, optional + * Whether to omit the HGVS coordinate columns this score set does not use, e.g. hgvs_nt for a + * protein-only score set. Defaults to False. * drop_na_columns : bool, optional - * Whether to drop columns that contain only NA values. Defaults to False. + * Deprecated spelling of drop_unused_hgvs_columns, accepted for one release. + * include_post_mapped_hgvs : bool, optional + * Deprecated: equivalent to requesting the "mavedb" namespace. Accepted for one release. + * include_custom_columns : bool, optional + * Deprecated: equivalent to requesting the "scores_custom" namespace. Accepted for one release. * db : Session * The database session to use. * user_data : Optional[UserData] @@ -10167,11 +10361,24 @@ export interface operations { start?: number; /** @description Maximum number of variants to return */ limit?: number; - /** @description One or more data types to include: "scores", "counts", "vep", "gnomad", "clingen", and/or ClinVar-versioned namespaces of the form "clinvar.YEAR_MONTH" (e.g. "clinvar.2024_01" for January 2024). */ + /** @description One or more groups of columns to include. Naming any group replaces the default set rather than adding to it, so list every group you want. Fixed groups: "scores", "scores_custom", "counts", "mavedb", "vep", "gnomad", "clingen", "score_set", "relationship". Versioned groups: "clinvar.YEAR_MONTH" (e.g. "clinvar.2024_01") for one ClinVar release, and "calibration." for one score calibration's functional and ACMG interpretation. Several ClinVar and calibration namespaces may be requested at once; each carries its release or URN in the column header. To discover which namespaces are available for a record, query the `csv-namespaces` endpoint. */ namespaces?: string[]; + drop_unused_hgvs_columns?: boolean | null; + /** + * @deprecated + * @description Deprecated: use `drop_unused_hgvs_columns`, which names what it actually does. This parameter only ever dropped the HGVS coordinate columns a score set does not use, never every NA column. It will be removed in a future release; `drop_unused_hgvs_columns` wins if both are given. + */ drop_na_columns?: boolean | null; - include_custom_columns?: boolean | null; + /** + * @deprecated + * @description Deprecated: request the `mavedb` namespace instead, e.g. `?namespaces=scores&namespaces=mavedb`. Passing true here is equivalent to appending that namespace. It will be removed in a future release. + */ include_post_mapped_hgvs?: boolean | null; + /** + * @deprecated + * @description Deprecated: request the `scores_custom` namespace instead. Passing true here is equivalent to appending that namespace, whose columns are emitted under the `scores` prefix as before. It will be removed in a future release. + */ + include_custom_columns?: boolean | null; }; header?: { "x-active-roles"?: string | null; @@ -10287,6 +10494,11 @@ export interface operations { start?: number; /** @description Number of variants to return */ limit?: number; + drop_unused_hgvs_columns?: boolean | null; + /** + * @deprecated + * @description Deprecated: use `drop_unused_hgvs_columns`, which names what it actually does. This parameter only ever dropped the HGVS coordinate columns a score set does not use, never every NA column. It will be removed in a future release; `drop_unused_hgvs_columns` wins if both are given. + */ drop_na_columns?: boolean | null; }; header?: { @@ -10348,6 +10560,11 @@ export interface operations { start?: number; /** @description Number of variants to return */ limit?: number; + drop_unused_hgvs_columns?: boolean | null; + /** + * @deprecated + * @description Deprecated: use `drop_unused_hgvs_columns`, which names what it actually does. This parameter only ever dropped the HGVS coordinate columns a score set does not use, never every NA column. It will be removed in a future release; `drop_unused_hgvs_columns` wins if both are given. + */ drop_na_columns?: boolean | null; }; header?: { @@ -12467,6 +12684,145 @@ export interface operations { }; }; }; + /** + * List the CSV column namespaces this variant has data for + * @description List the CSV column namespaces this variant has data for, labeled and grouped for a picker. + * + * Widens over the variant's equivalent measurements the same way the CSV does, so a calibration + * belonging to another score set that also measured this allele is offered here too. + * + * Parameters + * __________ + * urn : str + * The URN of the variant to inspect. + * db : Session + * The database session to use. + * user_data : Optional[UserData] + * The user data of the current user. If None, no user-specific permissions are checked. + * + * Returns + * _______ + * list[AvailableCsvNamespace] + * The namespaces with data, each with a human-readable label and group. + */ + get_variant_csv_namespaces_api_v1_variants__urn__csv_namespaces_get: { + parameters: { + header?: { + "x-active-roles"?: string | null; + }; + path: { + urn: string; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": components["schemas"]["AvailableCsvNamespace"][]; + }; + }; + /** @description Authentication required. */ + 401: { + content: never; + }; + /** @description Forbidden. Insufficient permissions. */ + 403: { + content: never; + }; + /** @description Resource not found. */ + 404: { + content: never; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + /** @description Internal server error. */ + 500: { + content: never; + }; + }; + }; + /** + * Get variant data in CSV format + * @description Return tabular data for a single variant, identified by URN, in CSV format. + * + * Where the variant-level annotation endpoints return nested VA-Spec objects, this flattens the same + * interpretation into columns a clinical information system can consume: ACMG criteria, evidence + * strengths, and evidence outcome codes alongside the measurement they were derived from. + * + * A row is emitted for every current measurement of the variant's ClinGen allele, so a variant assayed + * in several score sets yields several rows. The requested variant is always first. + * + * Parameters + * __________ + * urn : str + * The URN of the variant to fetch. + * namespaces : Optional[List[str]] + * The groups of columns to include. When omitted, the response includes the fixed groups plus one + * namespace per calibration eligible to annotate these measurements and the most recent ClinVar + * release covering them. + * db : Session + * The database session to use. + * user_data : Optional[UserData] + * The user data of the current user. If None, no user-specific permissions are checked. + * + * Returns + * _______ + * Any + * StreamingResponse containing the CSV data. + */ + get_variant_csv_data_api_v1_variants__urn__csv_get: { + parameters: { + query?: { + /** @description One or more groups of columns to include. Naming any group replaces the default set rather than adding to it, so list every group you want. Fixed groups: "scores", "scores_custom", "counts", "mavedb", "vep", "gnomad", "clingen", "score_set", "relationship". Versioned groups: "clinvar.YEAR_MONTH" (e.g. "clinvar.2024_01") for one ClinVar release, and "calibration." for one score calibration's functional and ACMG interpretation. Several ClinVar and calibration namespaces may be requested at once; each carries its release or URN in the column header. To discover which namespaces are available for a record, query the `csv-namespaces` endpoint. */ + namespaces?: string[] | null; + }; + header?: { + "x-active-roles"?: string | null; + }; + path: { + urn: string; + }; + }; + responses: { + /** @description Variant data in CSV format, one row per measurement of the variant's allele. Columns cover identity, mapped coordinates, the measured score, external annotations, and each requested calibration's functional and ACMG interpretation. */ + 200: { + content: { + "application/json": unknown; + "text/csv": unknown; + }; + }; + /** @description Bad request. Check parameters and payload. */ + 400: { + content: never; + }; + /** @description Authentication required. */ + 401: { + content: never; + }; + /** @description Forbidden. Insufficient permissions. */ + 403: { + content: never; + }; + /** @description Resource not found. */ + 404: { + content: never; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + /** @description Internal server error. */ + 500: { + content: never; + }; + }; + }; /** * Proxy Alphafold Index * @description Proxy the AlphaFold files index (XML document).