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

add coverage tracks for CNVs (percent callable, deletion burden, and duplication burden) #1358

Draft
wants to merge 12 commits into
base: main
Choose a base branch
from
Draft
8 changes: 4 additions & 4 deletions browser/src/CoverageTrack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,20 @@ const TopPanel = styled.div`
width: 100%;
`

const LegendWrapper = styled.ul`
export const LegendWrapper = styled.ul`
display: flex;
flex-direction: row;
padding: 0;
margin: 0 1em 0 0;
list-style-type: none;
`

const LegendItem = styled.li`
export const LegendItem = styled.li`
display: flex;
margin-left: 1em;
`

const LegendSwatch = styled.span`
export const LegendSwatch = styled.span`
display: inline-block;
width: 1em;
height: 1em;
Expand All @@ -46,7 +46,7 @@ const LegendSwatch = styled.span`
}
`

type LegendProps = {
export type LegendProps = {
datasets: {
color: string
name: string
Expand Down
172 changes: 172 additions & 0 deletions browser/src/DiscreteBarPlot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import React, { useRef } from 'react'
import styled from 'styled-components'
import { scaleLinear } from 'd3-scale'
import { LegendWrapper, LegendItem, LegendSwatch, LegendProps } from './CoverageTrack'

// @ts-expect-error TS(7016) FIXME: Could not find a declaration file for module '@gno... Remove this comment to see the full error message
import { Track } from '@gnomad/region-viewer'
import { Button } from '@gnomad/ui'
import { AxisLeft } from '@vx/axis'

const TopPanel = styled.div`
display: flex;
justify-content: flex-end;
align-items: center;
width: 100%;
`
const TitlePanel = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
height: 100%;
padding-right: 40px;
`
type PercentCallableValues = {
pos: number
percent_callable: number
xpos: number
}

type GroupedData = {
startPos: number
endPos: number
percent_callable: number
}

const Legend = ({ datasets }: LegendProps) => (
<LegendWrapper>
{datasets.map((dataset) => (
<LegendItem key={dataset.name}>
{/* @ts-expect-error TS(2769) FIXME: No overload matches this call. */}
<LegendSwatch color={dataset.color} opacity={dataset.opacity} />
{dataset.name}
</LegendItem>
))}
</LegendWrapper>
)

const groupDiscreteData = (buckets: PercentCallableValues[]): GroupedData[] => {
const groupedData: GroupedData[] = []

buckets.forEach((entry) => {
const prevEntry = groupedData.length > 0 ? groupedData[groupedData.length - 1] : null

if (prevEntry && prevEntry.percent_callable === entry.percent_callable) {
prevEntry.endPos = entry.pos + 1
} else {
groupedData.push({
startPos: entry.pos,
endPos: entry.pos,
percent_callable: entry.percent_callable,
})
}
})
return groupedData
}

const margin = {
top: 7,
left: 60,
}

const DiscreteBarPlot = ({
datasets,
height,
regionStart,
regionStop,
chrom,
}: {
datasets: { color: string; buckets: PercentCallableValues[]; name: string; opacity: number }[]
height: number
regionStart: number
regionStop: number
chrom: number
}) => {
const groupedData = groupDiscreteData(datasets[0].buckets)

const plotHeight = height + margin.top
const yScale = scaleLinear().domain([0, 1]).range([plotHeight, margin.top])

const plotRef = useRef(null)

const exportPlot = () => {
if (plotRef.current) {
const serializer = new XMLSerializer()
const data = serializer.serializeToString(plotRef.current)
const blob = new Blob(['<?xml version="1.0" standalone="no"?>\r\n', data], {
type: 'image/svg+xml;charset=utf-8',
})

const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${chrom}-${regionStart}-${regionStop}_percent_callable.svg`

document.body.appendChild(link)
link.click()
document.body.removeChild(link)

URL.revokeObjectURL(url)
}
}

return (
<Track
groupedData={groupedData}
renderLeftPanel={() => <TitlePanel> Percent Callable </TitlePanel>}
renderTopPanel={() => (
<TopPanel>
<Legend datasets={datasets} />
<Button style={{ marginLeft: '1em' }} onClick={exportPlot}>
Save plot
</Button>
</TopPanel>
)}
>
{({ width }: { width: number }) => {
const plotWidth = width - margin.left

const xDomain = [regionStart - 75, regionStop + 75]

const xScale = scaleLinear().domain(xDomain).range([0, plotWidth])
return (
<div style={{ marginLeft: -margin.left }}>
<svg ref={plotRef} width={width + margin.left} height={plotHeight}>
<AxisLeft
hideZero
left={margin.left}
tickLabelProps={() => ({
dx: '-0.25em',
dy: '0.25em',
fill: '#000',
fontSize: 10,
textAnchor: 'end',
})}
scale={yScale}
stroke="#333"
/>
<g transform={`translate(${margin.left},0)`}>
{/* eslint-disable react/no-array-index-key */}
{groupedData.map((entry: GroupedData, index: number) => {
return (
<rect
key={index}
x={xScale(entry.startPos)}
y={(1 - entry.percent_callable) * height + margin.top}
width={xScale(entry.endPos - entry.startPos + regionStart)}
height={entry.percent_callable * height}
fill="rgb(70, 130, 180)"
opacity={datasets[0].opacity}
/>
)
})}
<line x1={0} y1={plotHeight} x2={width} y2={plotHeight} stroke="#333" />
</g>
</svg>
</div>
)
}}
</Track>
)
}
export default DiscreteBarPlot
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React from 'react'
import { createRenderer } from 'react-test-renderer/shallow'

import { jest, describe, expect, test } from '@jest/globals'
import { mockQueries } from '../../../tests/__helpers__/queries'
import Query, { BaseQuery } from '../Query'

import CopyNumberVariantsGenePercentCallableTrack from './CopyNumberVariantsGenePercentCallableTrack'

import { allDatasetIds, hasCopyNumberVariantCoverage } from '@gnomad/dataset-metadata/metadata'
import geneFactory from '../__factories__/Gene'

jest.mock('../Query', () => {
const originalModule = jest.requireActual('../Query')

return {
__esModule: true,
...(originalModule as object),
default: jest.fn(),
BaseQuery: jest.fn(),
}
})

const { resetMockApiCalls, resetMockApiResponses, simulateApiResponse, setMockApiResponses } =
mockQueries()

beforeEach(() => {
Query.mockImplementation(
jest.fn(({ query, children, operationName, variables }) =>
simulateApiResponse('Query', query, children, operationName, variables)
)
)
;(BaseQuery as any).mockImplementation(
jest.fn(({ query, children, operationName, variables }) =>
simulateApiResponse('BaseQuery', query, children, operationName, variables)
)
)
})

afterEach(() => {
resetMockApiCalls()
resetMockApiResponses()
})

const datasetsWithCoverage = allDatasetIds.filter((datasetId) =>
hasCopyNumberVariantCoverage(datasetId)
)

describe.each(datasetsWithCoverage)(
'CopyNumberVariantsGenePercentCallableTrack with dataset %s',
(datasetId) => {
test('queries with appropriate params', () => {
const gene = geneFactory.build()
setMockApiResponses({
CopyNumberVariantsGenePercentCallableTrack: () => ({
gene: {
cnv_track_callable_coverage: [],
},
}),
})

const shallowRenderer = createRenderer()
shallowRenderer.render(
<CopyNumberVariantsGenePercentCallableTrack
datasetId={datasetId}
geneId={gene.gene_id}
/>
)

expect(shallowRenderer.getRenderOutput()).toMatchSnapshot()
})
}
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import React from 'react'

import { referenceGenome } from '@gnomad/dataset-metadata/metadata'
// import CoverageTrack from '../CoverageTrack'
import Query from '../Query'
import DiscreteBarPlot from '../DiscreteBarPlot'

type OwnProps = {
datasetId: string
chrom: string
start: number
stop: number
}

// @ts-expect-error TS(2456) FIXME: Type alias 'Props' circularly references itself.
type Props = OwnProps & typeof CopyNumberVariantsGenePercentCallableTrack.defaultProps

// @ts-expect-error TS(7022) FIXME: 'CopyNumberVariantsGenePercentCallableTrack' implicitly has type '... Remove this comment to see the full error message
const CopyNumberVariantsGenePercentCallableTrack = ({ datasetId, geneId }: Props) => {
const operationName = 'CopyNumberVariantsGenePercentCallableTrack'
const query = `
query ${operationName}($geneId: String!, $datasetId: CopyNumberVariantDatasetId!, $referenceGenome: ReferenceGenomeId!) {
gene(gene_id: $geneId, reference_genome: $referenceGenome) {
cnv_track_callable_coverage(dataset: $datasetId) {
xpos
percent_callable
position
contig
}
start
stop
chrom
}
}
`
return (
<Query
operationName={operationName}
query={query}
variables={{ geneId, datasetId, referenceGenome: referenceGenome(datasetId) }}
loadingMessage="Loading coverage"
loadingPlaceholderHeight={220}
errorMessage="Unable to load coverage"
success={(data: any) => {
if (!data.gene || !data.gene.cnv_track_callable_coverage) {
return false
}
return data.gene && data.gene.cnv_track_callable_coverage
}}
>
{({ data }: any) => {
const transformedArray = data.gene.cnv_track_callable_coverage.map((item: any) => ({
pos: item.position,
percent_callable: item.percent_callable,
xpos: item.xpos,
}))
transformedArray.sort((a: any, b: any) => a.xpos - b.xpos)

const coverage = [
{
color: 'rgb(70, 130, 180)',
buckets: transformedArray,
name: 'percent callable',
opacity: 0.7,
},
]
const geneStart = data.gene.start
const geneStop = data.gene.stop
const geneChrom = Number(data.gene.chrom)

return (
<DiscreteBarPlot
datasets={coverage}
height={200}
regionStart={geneStart}
regionStop={geneStop}
chrom={geneChrom}
/>
)
}}
</Query>
)
}

export default CopyNumberVariantsGenePercentCallableTrack
Loading
Loading