Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Variation Landscape downsampling (#462) #122

Merged
merged 4 commits into from
Feb 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/api/mehari/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@ import path from 'path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import createFetchMock from 'vitest-fetch-mock'

import { GenomeBuild } from '../../pbs/mehari/txs'

import { SeqvarImpl } from '../../lib/genomicVars'
import { LinearStrucvarImpl } from '../../lib/genomicVars'
import { GenomeBuild } from '../../pbs/mehari/txs'
import { MehariClient } from './client'

/** Fixture Seqvar */
Expand Down
3 changes: 1 addition & 2 deletions src/api/mehari/client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { GeneTranscriptsResponse } from '../../pbs/mehari/server'

import type { LinearStrucvar, Seqvar } from '../../lib/genomicVars'
import { urlConfig } from '../../lib/urlConfig'
import { GeneTranscriptsResponse } from '../../pbs/mehari/server'
import { GenomeBuild } from '../../pbs/mehari/txs'
import { SeqvarResult, StrucvarResult } from './types'

Expand Down
91 changes: 91 additions & 0 deletions src/components/GeneClinvarCard/VariationLandscapePlotly.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import fs from 'fs'
import path from 'path'
import { describe, expect, test } from 'vitest'

import type { GenomeBuild } from '../../lib/genomeBuilds'
import { setupMountedComponents } from '../../lib/testUtils'
import { ClinvarPerGeneRecord } from '../../pbs/annonars/clinvar/per_gene'
import { GeneTranscriptsResponse } from '../../pbs/mehari/server'
import { type Transcript } from '../../pbs/mehari/txs'
import VariationLandscapePlotly from './VariationLandscapePlotly.vue'

// Load fixture data for gene TGDS (little data) and BRCA1 (lots of data).
const clinvarPerGeneTgds = ClinvarPerGeneRecord.fromJsonString(
fs.readFileSync(path.resolve(__dirname, './fixture.clinvarPerGene.TGDS.json'), 'utf8')
)
const clinvarPerGeneBrca1 = ClinvarPerGeneRecord.fromJsonString(
fs.readFileSync(path.resolve(__dirname, './fixture.clinvarPerGene.BRCA1.json'), 'utf8')
)
const genesTxsTgds37 = GeneTranscriptsResponse.fromJsonString(
fs.readFileSync(path.resolve(__dirname, './fixture.genesTxs.TGDS.37.json'), 'utf8')
)
const genesTxsTgds38 = GeneTranscriptsResponse.fromJsonString(
fs.readFileSync(path.resolve(__dirname, './fixture.genesTxs.TGDS.38.json'), 'utf8')
)
const genesTxsBrca137 = GeneTranscriptsResponse.fromJsonString(
fs.readFileSync(path.resolve(__dirname, './fixture.genesTxs.BRCA1.37.json'), 'utf8')
)

describe.concurrent('VariationLandscapePlotly.vue', async () => {
test.each([
['TGDS', 'grch37', genesTxsTgds37.transcripts, clinvarPerGeneTgds],
['TGDS', 'grch38', genesTxsTgds38.transcripts, clinvarPerGeneTgds],
['BRCA1', 'grch37', genesTxsBrca137.transcripts, clinvarPerGeneBrca1]
])(
'renders the plot for %s, %s',
async (
geneSymbol: string,
genomeBuild: string,
transcripts: Transcript[],
clinvarPerGene: ClinvarPerGeneRecord
) => {
// arrange:
const { wrapper } = await setupMountedComponents(
{ component: VariationLandscapePlotly },
{
props: {
geneSymbol,
genomeBuild: genomeBuild as GenomeBuild,
transcripts,
clinvarPerGene
}
}
)

// act: nothing, only test rendering

// assert:
expect(wrapper.text()).toContain('The plot above shows')
expect(wrapper.text()).toContain(geneSymbol)
const plotlyPlot = wrapper.findComponent({ props: { id: 'plot' } })
expect(plotlyPlot.exists()).toBe(false) // TODO: should be true, but due to async loading of plotly, it is false
}
)

test.each([['geneSymbol'], ['genomeBuild'], ['transcripts'], ['clinvarPerGene']])(
'renders the plot as smoke test with undefined %s',
async (prop: string) => {
// arrange:
const props = {
geneSymbol: 'TGDS',
genomeBuild: 'grch37',
genesTxsTgds37,
clinvarPerGeneTgds
}
// @ts-ignore
props[prop] = undefined
const { wrapper } = await setupMountedComponents(
{ component: VariationLandscapePlotly },
{
props
}
)

// act: nothing, only test rendering

// assert:
const plotlyPlot = wrapper.findComponent({ props: { id: 'plot' } })
expect(plotlyPlot.exists()).toBe(false) // TODO: should be true, but due to async loading of plotly, it is false
}
)
})
131 changes: 63 additions & 68 deletions src/components/GeneClinvarCard/VariationLandscapePlotly.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
* This component shows the ClinVar "variation landscape".
*/
import Plotly from 'plotly.js-dist'
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'

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'
import { Transcript } from '../../pbs/mehari/txs'
import { type PlotlyDataPoint, downsample } from './lib'

/** This component's props. */
const props = withDefaults(
Expand All @@ -31,6 +32,13 @@
}
)

/** The current plot boundaries. */
const currentPlotBoundaries = ref({
minX: 0,
maxX: 0
})

/* Enumeration clinical significance. */
const CLINVAR_SIGNIFICANCE_TO_INT: { [Key in ClinicalSignificance]: number } = {
[ClinicalSignificance.CLINICAL_SIGNIFICANCE_UNKNOWN]: -3,
[ClinicalSignificance.CLINICAL_SIGNIFICANCE_PATHOGENIC]: 2,
Expand All @@ -40,15 +48,15 @@
[ClinicalSignificance.CLINICAL_SIGNIFICANCE_BENIGN]: -2
}

/* Convert clinical significance to integer. */
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 */
/* Helper function to compute color for point value. */
const markerColor = (value: number) => {
if (value === 2) {
return 'red'
Expand All @@ -65,23 +73,6 @@
}
}

/** 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<PlotlyDataPoint[]>(() => {
if (!props.clinvarPerGene) {
Expand All @@ -101,46 +92,28 @@
}
return clinvarInfo.map((variant: ClinvarRecord) => ({
x: variant.start,
y: convertClinvarSignificance(variant.referenceAssertions[0].clinicalSignificance)
y: convertClinvarSignificance(variant.referenceAssertions[0].clinicalSignificance),
count: 1
}))
})

/** 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) {
// If there are less then 700 variants, return the data TODO
if (clinvarData.value.length < 700) {
return clinvarData.value
}
const windowSize = (currentPlotBoundaries.value.maxX - currentPlotBoundaries.value.minX) / 800
return downsample(clinvarData.value, windowSize)
const windowSize = (currentPlotBoundaries.value.maxX - currentPlotBoundaries.value.minX) / 700
return downsample(

Check warning on line 107 in src/components/GeneClinvarCard/VariationLandscapePlotly.vue

View check run for this annotation

Codecov / codecov/patch

src/components/GeneClinvarCard/VariationLandscapePlotly.vue#L106-L107

Added lines #L106 - L107 were not covered by tests
clinvarData.value,
windowSize,
currentPlotBoundaries.value.minX,
currentPlotBoundaries.value.maxX
) as PlotlyDataPoint[]
// 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',
Expand All @@ -150,12 +123,14 @@
x: plotlyData.value ? plotlyData.value.map((item) => item.x) : [],
ysrc: 'caiotaniguchi:3:9f1314',
y: plotlyData.value ? plotlyData.value.map((item) => item.y) : [],
text: plotlyData.value ? plotlyData.value.map((item) => `Count: ${item.count}`) : [], // Hover text
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) {
Expand All @@ -179,6 +154,7 @@
}
return sticks
})

/** Exons for the gene */
const exons = computed(() => {
if (!props.transcripts) {
Expand All @@ -197,6 +173,7 @@
}
return exons
})

// Horizontal line at y=3
const horizontalLine = {
type: 'line',
Expand All @@ -211,6 +188,7 @@
width: 2
}
}

// Grey rectangles for exons
const exonShapes = exons.value.map((exon) => ({
type: 'rect',
Expand All @@ -225,6 +203,7 @@
width: 0
}
}))

/** Plot layout */
const layout = {
title: 'Variation Landscape',
Expand Down Expand Up @@ -258,12 +237,13 @@
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 windowSize = (maxX - minX) / 700

Check warning on line 244 in src/components/GeneClinvarCard/VariationLandscapePlotly.vue

View check run for this annotation

Codecov / codecov/patch

src/components/GeneClinvarCard/VariationLandscapePlotly.vue#L244

Added line #L244 was not covered by tests
const filteredClinvarData = clinvarData.value.filter((item) => item.x >= minX && item.x <= maxX)
const newDownsampledData = downsample(filteredClinvarData, windowSize)
const newDownsampledData = downsample(filteredClinvarData, windowSize, minX, maxX)

Check warning on line 246 in src/components/GeneClinvarCard/VariationLandscapePlotly.vue

View check run for this annotation

Codecov / codecov/patch

src/components/GeneClinvarCard/VariationLandscapePlotly.vue#L246

Added line #L246 was not covered by tests
trace.x = newDownsampledData.map((item) => item.x)
trace.y = newDownsampledData.map((item) => item.y)
trace.marker.color = newDownsampledData.map((item) => markerColor(item.y))
Expand All @@ -288,27 +268,42 @@
// 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]'])
const plotElement = document.getElementById('plot')
if (plotElement) {
Plotly.newPlot('plot', [trace], layout, { scrollZoom: true }).then(() => {

Check warning on line 275 in src/components/GeneClinvarCard/VariationLandscapePlotly.vue

View check run for this annotation

Codecov / codecov/patch

src/components/GeneClinvarCard/VariationLandscapePlotly.vue#L275

Added line #L275 was not covered by tests
// @ts-expect-error Property 'on' seems to exist on type 'HTMLElement'
document.getElementById('plot')?.on('plotly_relayout', (eventdata: any) => {

Check warning on line 277 in src/components/GeneClinvarCard/VariationLandscapePlotly.vue

View check run for this annotation

Codecov / codecov/patch

src/components/GeneClinvarCard/VariationLandscapePlotly.vue#L277

Added line #L277 was not covered by tests
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)

Check warning on line 283 in src/components/GeneClinvarCard/VariationLandscapePlotly.vue

View check run for this annotation

Codecov / codecov/patch

src/components/GeneClinvarCard/VariationLandscapePlotly.vue#L280-L283

Added lines #L280 - L283 were not covered by tests
}
})
// @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

Check warning on line 289 in src/components/GeneClinvarCard/VariationLandscapePlotly.vue

View check run for this annotation

Codecov / codecov/patch

src/components/GeneClinvarCard/VariationLandscapePlotly.vue#L287-L289

Added lines #L287 - L289 were not covered by tests
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)
})
})
}
})

watch(
clinvarData,
(newValue, oldValue) => {
const plotElement = document.getElementById('plot')

Check warning on line 300 in src/components/GeneClinvarCard/VariationLandscapePlotly.vue

View check run for this annotation

Codecov / codecov/patch

src/components/GeneClinvarCard/VariationLandscapePlotly.vue#L299-L300

Added lines #L299 - L300 were not covered by tests
if (plotElement && newValue !== oldValue) {
updatePlotData(currentPlotBoundaries.value.minX, currentPlotBoundaries.value.maxX)

Check warning on line 302 in src/components/GeneClinvarCard/VariationLandscapePlotly.vue

View check run for this annotation

Codecov / codecov/patch

src/components/GeneClinvarCard/VariationLandscapePlotly.vue#L302

Added line #L302 was not covered by tests
}
},
{ deep: true }
)
</script>

<template>
Expand Down
Loading
Loading