From ec7641ed4579793721fb65401af2f5956d91866d Mon Sep 17 00:00:00 2001 From: Manuel Holtgrewe Date: Tue, 6 Feb 2024 09:34:56 +0100 Subject: [PATCH] feat: integrate plotly-based clinvar landscape (#94) --- package-lock.json | 41 +++ package.json | 3 + .../GeneClinvarCard/GeneClinvarCard.spec.ts | 2 +- .../GeneClinvarCard/GeneClinvarCard.vue | 7 +- .../VariationLandscapePlotly.vue | 334 ++++++++++++++++++ src/lib/modulesDeclarations.d.ts | 5 + vitest.config.mts | 2 +- 7 files changed, 390 insertions(+), 4 deletions(-) create mode 100644 src/components/GeneClinvarCard/VariationLandscapePlotly.vue create mode 100644 src/lib/modulesDeclarations.d.ts diff --git a/package-lock.json b/package-lock.json index 564a68f..b35bf39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,10 @@ "@protobuf-ts/runtime": "^2.9.3", "@reactgular/chunks": "^1.0.1", "@types/luxon": "^3.4.2", + "downsample": "^1.4.0", "igv": "^2.15.11", "luxon": "^3.4.4", + "plotly.js-dist": "^2.29.0", "title-case": "^4.3.1", "vega": "^5.27.0", "vega-embed": "^6.24.0", @@ -49,6 +51,7 @@ "eslint-plugin-storybook": "^0.6.15", "eslint-plugin-vue": "^9.21.1", "jsdom": "^24.0.0", + "jsdom-worker": "^0.3.0", "path": "^0.12.7", "prettier": "^3.2.5", "react": "^18.2.0", @@ -9168,6 +9171,11 @@ "node": ">=12" } }, + "node_modules/downsample": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/downsample/-/downsample-1.4.0.tgz", + "integrity": "sha512-teYPhUPxqwtyICt47t1mP/LjhbRV/ghuKb/LmFDbcZ0CjqFD31tn6rVLZoeCEa1xr8+f2skW8UjRiLiGIKQE4w==" + }, "node_modules/duplexify": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", @@ -12862,6 +12870,19 @@ } } }, + "node_modules/jsdom-worker": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/jsdom-worker/-/jsdom-worker-0.3.0.tgz", + "integrity": "sha512-nlPmN0i93+e6vxzov8xqLMR+MBs/TAYeSviehivzqovHH0AgooVx9pQ/otrygASppPvdR+V9Jqx5SMe8+FcADg==", + "dev": true, + "dependencies": { + "mitt": "^3.0.0", + "uuid-v4": "^0.1.0" + }, + "peerDependencies": { + "node-fetch": "*" + } + }, "node_modules/jsdom/node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -14697,6 +14718,12 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -15711,6 +15738,11 @@ "pathe": "^1.1.0" } }, + "node_modules/plotly.js-dist": { + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/plotly.js-dist/-/plotly.js-dist-2.29.0.tgz", + "integrity": "sha512-RwVeITXolsqZEQJfWIGJB7EABaKAQo9uf+Bz+DICftY2o4idRoD9kOmxQ5YjpbifISBf9udFhYA5FY/vINlK+g==" + }, "node_modules/polished": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", @@ -19092,6 +19124,15 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/uuid-v4": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/uuid-v4/-/uuid-v4-0.1.0.tgz", + "integrity": "sha512-m11RYDtowtAIihBXMoGajOEKpAXrKbpKlpmxqyztMYQNGSY5nZAZ/oYch/w2HNS1RMA4WLGcZvuD8/wFMuCEzA==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/uvu": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz", diff --git a/package.json b/package.json index 1c6cab6..a9d883c 100644 --- a/package.json +++ b/package.json @@ -50,8 +50,10 @@ "@protobuf-ts/runtime": "^2.9.3", "@reactgular/chunks": "^1.0.1", "@types/luxon": "^3.4.2", + "downsample": "^1.4.0", "igv": "^2.15.11", "luxon": "^3.4.4", + "plotly.js-dist": "^2.29.0", "title-case": "^4.3.1", "vega": "^5.27.0", "vega-embed": "^6.24.0", @@ -87,6 +89,7 @@ "eslint-plugin-storybook": "^0.6.15", "eslint-plugin-vue": "^9.21.1", "jsdom": "^24.0.0", + "jsdom-worker": "^0.3.0", "path": "^0.12.7", "prettier": "^3.2.5", "react": "^18.2.0", diff --git a/src/components/GeneClinvarCard/GeneClinvarCard.spec.ts b/src/components/GeneClinvarCard/GeneClinvarCard.spec.ts index 594637c..1c82556 100644 --- a/src/components/GeneClinvarCard/GeneClinvarCard.spec.ts +++ b/src/components/GeneClinvarCard/GeneClinvarCard.spec.ts @@ -70,7 +70,7 @@ describe.concurrent('GeneClinvarCard.vue', async () => { expect(wrapper.text()).toContain('ClinVar Information') const clinvarImpact = wrapper.findComponent({ name: 'ClinvarImpact' }) expect(clinvarImpact.exists()).toBe(true) - const variationLandscape = wrapper.findComponent({ name: 'VariationLandscape' }) + const variationLandscape = wrapper.findComponent({ name: 'VariationLandscapePlotly' }) expect(variationLandscape.exists()).toBe(true) const clinvarFreqPlot = wrapper.findComponent({ name: 'ClinvarFreqPlot' }) expect(clinvarFreqPlot.exists()).toBe(true) diff --git a/src/components/GeneClinvarCard/GeneClinvarCard.vue b/src/components/GeneClinvarCard/GeneClinvarCard.vue index 5c3e3e7..e38e70b 100644 --- a/src/components/GeneClinvarCard/GeneClinvarCard.vue +++ b/src/components/GeneClinvarCard/GeneClinvarCard.vue @@ -15,8 +15,11 @@ import ClinvarImpact from './ClinvarImpact.vue' // The variation landascape is defined as an async component as rendering it // may take a while. -const VariationLandscape = defineAsyncComponent(() => import('./VariationLandscape.vue')) +const VariationLandscapePlotly = defineAsyncComponent( + () => import('./VariationLandscapePlotly.vue') +) +/** This component's props. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars const props = defineProps<{ /** Gene per clinvar */ @@ -53,7 +56,7 @@ const props = defineProps<{ - +/** + * This component shows the ClinVar "variation landscape". + */ +import Plotly from 'plotly.js-dist' +import { computed, onMounted, ref } from 'vue' + +import { TranscriptResult } from '../../api/dotty' +import type { GenomeBuild } from '../../lib/genomeBuilds' +import { ClinicalSignificance } from '../../pbs/annonars/clinvar/minimal' +import { Record as ClinvarRecord } from '../../pbs/annonars/clinvar/minimal' +import { ClinvarPerGeneRecord } from '../../pbs/annonars/clinvar/per_gene' + +/** This component's props. */ +const props = withDefaults( + defineProps<{ + /** Gene information from annonars. */ + clinvarPerGene?: ClinvarPerGeneRecord + /** Transctipts information. */ + transcripts?: TranscriptResult + /** The genome release. */ + genomeBuild?: GenomeBuild + /** Gene symbol */ + geneSymbol?: string + }>(), + { + clinvarPerGene: undefined, + transcripts: undefined, + genomeBuild: 'grch37', + geneSymbol: undefined + } +) + +const CLINVAR_SIGNIFICANCE_TO_INT: { [Key in ClinicalSignificance]: number } = { + [ClinicalSignificance.CLINICAL_SIGNIFICANCE_UNKNOWN]: -3, + [ClinicalSignificance.CLINICAL_SIGNIFICANCE_PATHOGENIC]: 2, + [ClinicalSignificance.CLINICAL_SIGNIFICANCE_LIKELY_PATHOGENIC]: 1, + [ClinicalSignificance.CLINICAL_SIGNIFICANCE_UNCERTAIN_SIGNIFICANCE]: 0, + [ClinicalSignificance.CLINICAL_SIGNIFICANCE_LIKELY_BENIGN]: -1, + [ClinicalSignificance.CLINICAL_SIGNIFICANCE_BENIGN]: -2 +} + +const convertClinvarSignificance = (input: ClinicalSignificance): number => { + if (input in CLINVAR_SIGNIFICANCE_TO_INT) { + return CLINVAR_SIGNIFICANCE_TO_INT[input] + } else { + return -4 + } +} + +/* Compute color for each point */ +const markerColor = (value: number) => { + if (value === 2) { + return 'red' + } else if (value === 1) { + return 'orange' + } else if (value === 0) { + return 'yellow' + } else if (value === -1) { + return 'lightgreen' + } else if (value === -2) { + return 'green' + } else { + return 'grey' + } +} + +/** Interface for plotly data point. */ +interface PlotlyDataPoint { + x: number + y: number +} +/** Interface for downsampled data point. */ +interface DownsampledDataPoint { + x: number + y: number + count: number +} +/** The current plot boundaries. */ +const currentPlotBoundaries = ref({ + minX: 0, + maxX: 0 +}) + +/** Compute the min and max values for the plot */ +const clinvarData = computed(() => { + if (!props.clinvarPerGene) { + return [] + } + let clinvarInfo: ClinvarRecord[] = [] + for (const item of props.clinvarPerGene.variants) { + if (item.genomeRelease.toLowerCase() == props.genomeBuild) { + clinvarInfo = item.variants.sort((a: ClinvarRecord, b: ClinvarRecord) => a.start - b.start) + } + } + // Update plot boundaries + // eslint-disable-next-line vue/no-side-effects-in-computed-properties + currentPlotBoundaries.value = { + minX: clinvarInfo[0].start, + maxX: clinvarInfo[clinvarInfo.length - 1].start + } + return clinvarInfo.map((variant: ClinvarRecord) => ({ + x: variant.start, + y: convertClinvarSignificance(variant.referenceAssertions[0].clinicalSignificance) + })) +}) + +/** Helper function for downstreaming data. + * @param data - The data to downsample. + * @param windowSize - The size of the window to downsample. + * @returns The downsampled data. + */ +const downsample = (data: PlotlyDataPoint[], windowSize: number): DownsampledDataPoint[] => { + if (data.length === 0) return [] + // If there are less then 800 variants, do not downsample + if (data.length < 800) return data.map((item) => ({ x: item.x, y: item.y, count: 1 })) + const minX = data[0].x + const maxX = data[data.length - 1].x + const bins: DownsampledDataPoint[] = [] + for (let x = minX; x <= maxX; x += windowSize) { + for (let y = -3; y <= 2; y++) { + bins.push({ x: x + windowSize / 2, y: y, count: 0 }) + } + } + data.forEach((point) => { + const binIndex = Math.floor((point.x - minX) / windowSize) + const bin = bins[binIndex * 5 + (point.y + 3)] + bin.count++ + }) + // Return only bins with count > 0 + return bins.filter((bin) => bin.count > 0) +} +/** Downsampled data. */ +const plotlyData = computed(() => { + // If there are less then 800 variants, return the data + if (clinvarData.value.length < 800) { + return clinvarData.value + } + const windowSize = (currentPlotBoundaries.value.maxX - currentPlotBoundaries.value.minX) / 800 + return downsample(clinvarData.value, windowSize) + // DEBUG: Uncomment the line below to return the full data and compare with the original plot + // return clinvarData.value +}) +/** Plotly trace */ +const trace = { + uid: 'fc47f27b-f3b0-4d31-8dac-9782780ba6b8', + mode: 'markers', + type: 'scatter', + xsrc: 'caiotaniguchi:3:45beec', + x: plotlyData.value ? plotlyData.value.map((item) => item.x) : [], + ysrc: 'caiotaniguchi:3:9f1314', + y: plotlyData.value ? plotlyData.value.map((item) => item.y) : [], + marker: { + color: plotlyData.value ? plotlyData.value.map((item) => markerColor(item.y)) : [], + size: 10 + }, + scaleanchor: 'y' +} +/** Initial Lollipop sticks for each variant */ +const initiallollipopSticks = computed(() => { + if (!props.clinvarPerGene) { + return [] + } + const sticks = [] + for (const variant of plotlyData.value) { + sticks.push({ + x0: variant.x, + x1: variant.x, + y0: 0, + y1: variant.y, + line: { + color: markerColor(variant.y), + width: 0.2 + }, + type: 'line', + xref: 'x', + yref: 'y' + }) + } + return sticks +}) +/** Exons for the gene */ +const exons = computed(() => { + if (!props.transcripts) { + return [] + } + const exons = [] + for (const transcript of props.transcripts.transcripts) { + for (const alignment of transcript.alignments) { + for (const exon of alignment.exons) { + exons.push({ + start: exon.refStart, + stop: exon.refEnd + }) + } + } + } + return exons +}) +// Horizontal line at y=3 +const horizontalLine = { + type: 'line', + x0: 0, + y0: 3, + x1: 1, + y1: 3, + xref: 'paper', // This makes the line span the entire width of the plot area + yref: 'y', + line: { + color: 'grey', + width: 2 + } +} +// Grey rectangles for exons +const exonShapes = exons.value.map((exon) => ({ + type: 'rect', + x0: exon.start, + x1: exon.stop, + y0: 2.9, // Slightly below y=3 for visualization + y1: 3.1, // Slightly above y=3 for visualization + xref: 'x', + yref: 'y', + fillcolor: 'grey', + line: { + width: 0 + } +})) +/** Plot layout */ +const layout = { + title: 'Variation Landscape', + xaxis: { + type: 'linear', + autorange: true + }, + yaxis: { + type: 'linear', + autorange: false, + range: [-3.5, 3.5], + tickvals: [3, 2, 1, 0, -1, -2, -3], + ticktext: [ + 'Exons', + 'Pathogenic', + 'Likely pathogenic', + 'Uncertain significance', + 'Likely benign', + 'Benign', + 'Unknown' + ], + fixedrange: true + }, + shapes: [...initiallollipopSticks.value, ...exonShapes, horizontalLine], + autosize: true, + margin: { + l: 150, + r: 50, + b: 50, + t: 50, + pad: 4 + } +} +/** Method to update plot with new data based on zoom or pan */ +const updatePlotData = (minX: number, maxX: number) => { + // Filter clinvarData for new boundaries and compute new downsampled data + const windowSize = (maxX - minX) / 800 + const filteredClinvarData = clinvarData.value.filter((item) => item.x >= minX && item.x <= maxX) + const newDownsampledData = downsample(filteredClinvarData, windowSize) + trace.x = newDownsampledData.map((item) => item.x) + trace.y = newDownsampledData.map((item) => item.y) + trace.marker.color = newDownsampledData.map((item) => markerColor(item.y)) + // Update lollipopSticks + const newLollipopSticks = [] + for (const variant of newDownsampledData) { + newLollipopSticks.push({ + x0: variant.x, + x1: variant.x, + y0: 0, + y1: variant.y, + line: { + color: markerColor(variant.y), + width: 0.2 + }, + type: 'line', + xref: 'x', + yref: 'y' + }) + } + layout.shapes = [...newLollipopSticks, ...exonShapes, horizontalLine] + // Re-render plot with new data + Plotly.react('plot', [trace], layout) +} +onMounted(() => { + Plotly.newPlot('plot', [trace], layout, { scrollZoom: true }).then(() => { + // @ts-expect-error Property 'on' seems to exist on type 'HTMLElement' + document.getElementById('plot')?.on('plotly_relayout', (eventdata: any) => { + if (eventdata['xaxis.range[0]'] && eventdata['xaxis.range[1]']) { + // Update plot boundaries based on zoom or pan + const minX = parseFloat(eventdata['xaxis.range[0]']) + const maxX = parseFloat(eventdata['xaxis.range[1]']) + currentPlotBoundaries.value = { minX, maxX } + updatePlotData(minX, maxX) + } + }) + // @ts-expect-error Property 'on' seems to exist on type 'HTMLElement' + document.getElementById('plot')?.on('plotly_doubleclick', () => { + const minX = clinvarData.value[0].x + const maxX = clinvarData.value[clinvarData.value.length - 1].x + currentPlotBoundaries.value = { minX, maxX } + updatePlotData(minX, maxX) + }) + }) +}) + + + diff --git a/src/lib/modulesDeclarations.d.ts b/src/lib/modulesDeclarations.d.ts new file mode 100644 index 0000000..a9530c9 --- /dev/null +++ b/src/lib/modulesDeclarations.d.ts @@ -0,0 +1,5 @@ +// Declare igv module +declare module 'igv' + +// Declare plotly.js-dist module +declare module 'plotly.js-dist' diff --git a/vitest.config.mts b/vitest.config.mts index 546c7cc..d6e4df2 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -8,7 +8,7 @@ export default mergeConfig( viteConfig, defineConfig({ test: { - setupFiles: ['./src/vitest.setup.ts'], + setupFiles: ['jsdom-worker', './src/vitest.setup.ts'], server: { deps: { inline: ['vuetify', 'vitest-canvas-mock']