Skip to content

Commit

Permalink
feat: adding GeneExpressionCard component (#26)
Browse files Browse the repository at this point in the history
  • Loading branch information
holtgrewe authored Jan 26, 2024
1 parent 01b5040 commit b491224
Show file tree
Hide file tree
Showing 9 changed files with 1,529 additions and 21 deletions.
855 changes: 834 additions & 21 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
"dependencies": {
"@mdi/font": "^7.4.47",
"@protobuf-ts/runtime": "^2.9.3",
"vega": "^5.27.0",
"vega-embed": "^6.24.0",
"vite-plugin-css-injected-by-js": "^3.3.1",
"vite-plugin-static-copy": "^1.0.1",
"vue": "^3.3.11",
Expand Down
107 changes: 107 additions & 0 deletions src/components/GeneExpressionCard/GeneExpressionCard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect, it, vi } from 'vitest'

import { setupMountedComponents } from '@/lib/testUtils'
import { GtexTissueRecord } from '@/pbs/annonars/genes/base'

import GeneExpressionCard from './GeneExpressionCard.vue'

// A few GtexTissueRecords as test data
const fixtureGtexTissueRecords = [
{
tissue: 'GTEX_TISSUE_ADIPOSE_TISSUE',
tissueDetailed: 'GTEX_TISSUE_DETAILED_ADIPOSE_SUBCUTANEOUS',
tpms: [7.357, 12.695, 14.5, 16.74, 31.88]
},
{
tissue: 'GTEX_TISSUE_ADIPOSE_TISSUE',
tissueDetailed: 'GTEX_TISSUE_DETAILED_ADIPOSE_VISCERAL_OMENTUM',
tpms: [1.562, 12.45, 15.63, 19.15, 31.99]
},
{
tissue: 'GTEX_TISSUE_ADRENAL_GLAND',
tissueDetailed: 'GTEX_TISSUE_DETAILED_ADRENAL_GLAND',
tpms: [2.171, 10.375, 12.23, 14.1975, 32.68]
}
].map((record) => GtexTissueRecord.fromJson(record))

describe.concurrent('GeneExpressionCard', async () => {
it('renders the GeneExpressionCard with data', async () => {
// arrange:
// Disable warinings, because of invalid test data
console.warn = vi.fn()
const { wrapper } = await setupMountedComponents(
{ component: GeneExpressionCard },
{
props: {
geneSymbol: 'BRCA1',
expressionRecords: fixtureGtexTissueRecords,
ensemblGeneId: 'ENSG00000012048'
}
}
)

// act: nothing, only test rendering

// assert:
expect(wrapper.text()).toContain('Tissue-Specific Gene Expression from GTeX')
const vegaPlot = wrapper.findComponent({ name: 'VegaPlot' })
expect(vegaPlot.exists()).toBe(true)
// Find gtex linkout
const gtexLink = wrapper.find('#expression-card-gtex-portal')
expect(gtexLink.exists()).toBe(true)
expect(gtexLink.attributes('href')).toBe('https://gtexportal.org/home/gene/ENSG00000012048')
})

it('renders skelleton loader if ENSEMBL gene ID is undefined', async () => {
// arrange:
// Disable warinings, because of invalid test data
console.warn = vi.fn()
const { wrapper } = await setupMountedComponents(
{ component: GeneExpressionCard },
{
props: {
geneSymbol: 'BRCA1',
expressionRecords: fixtureGtexTissueRecords,
ensemblGeneId: undefined
}
}
)

// act: nothing, only test rendering

// assert:
expect(wrapper.text()).not.toContain('Tissue-Specific Gene Expression from GTeX')
const vegaPlot = wrapper.findComponent({ name: 'VegaPlot' })
expect(vegaPlot.exists()).toBe(false)
// Find VSkeletonLoader
const skeletonLoader = wrapper.findComponent({ name: 'VSkeletonLoader' })
expect(skeletonLoader.exists()).toBe(true)
})

it('does not blow up with undefined data', async () => {
// arrange:
// Disable warinings, because of invalid test data
console.warn = vi.fn()
const { wrapper } = await setupMountedComponents(
{ component: GeneExpressionCard },
{
props: {
geneSymbol: 'BRCA1',
expressionRecords: undefined,
ensemblGeneId: 'ENSG00000012048'
}
}
)

// act: nothing, only test rendering

// assert:
expect(wrapper.text()).toContain('Tissue-Specific Gene Expression from GTeX')
const vegaPlot = wrapper.findComponent({ name: 'VegaPlot' })
expect(vegaPlot.exists()).toBe(true)
// Find gtex linkout
const gtexLink = wrapper.find('#expression-card-gtex-portal')
expect(gtexLink.exists()).toBe(true)
expect(gtexLink.attributes('href')).toBe('https://gtexportal.org/home/gene/ENSG00000012048')
})
})
56 changes: 56 additions & 0 deletions src/components/GeneExpressionCard/GeneExpressionCard.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { JsonValue } from '@protobuf-ts/runtime'
import type { Meta, StoryObj } from '@storybook/vue3'

import { Record as GeneInfoRecord } from '../../pbs/annonars/genes/base'
import geneInfoBrca1Json from '../GenePathogenicityCard/fixture.geneInfo.BRCA1.json'
import geneInfoTgdsJson from '../GenePathogenicityCard/fixture.geneInfo.TGDS.json'
import GeneExpressionCard from './GeneExpressionCard.vue'

// Here, fixture data is loaded via `import` from JSON file. Reading the file
// as in the tests fails with "process is not defined" error in the browser.

// @ts-ignore
const geneInfoTgds = GeneInfoRecord.fromJson(geneInfoTgdsJson as JsonValue)
// @ts-ignore
const geneInfoBrca1 = GeneInfoRecord.fromJson(geneInfoBrca1Json as JsonValue)

const meta = {
title: 'Gene/GeneExpressionCard',
component: GeneExpressionCard,
tags: ['autodocs'],
argTypes: {
geneSymbol: { control: 'text' },
expressionRecords: { control: 'object' },
ensemblGeneId: { control: 'text' }
},
args: {
geneSymbol: geneInfoBrca1.hgnc!.symbol,
expressionRecords: geneInfoBrca1.gtex!.records,
ensemblGeneId: geneInfoBrca1.hgnc!.ensemblGeneId
}
} satisfies Meta<typeof GeneExpressionCard>

export default meta
type Story = StoryObj<typeof meta>

export const TGDS: Story = {
args: {
geneSymbol: geneInfoTgds.hgnc!.symbol,
expressionRecords: geneInfoTgds.gtex!.records,
ensemblGeneId: geneInfoTgds.hgnc!.ensemblGeneId
}
}

export const BRCA1: Story = {
args: {
geneSymbol: geneInfoBrca1.hgnc!.symbol,
expressionRecords: geneInfoBrca1.gtex!.records,
ensemblGeneId: geneInfoBrca1.hgnc!.ensemblGeneId
}
}

export const UndefinedGeneSymbol: Story = {
args: {
ensemblGeneId: undefined
}
}
192 changes: 192 additions & 0 deletions src/components/GeneExpressionCard/GeneExpressionCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
<script setup lang="ts">
/**
* This component displays a VCard with gene expression information from GTEx.
*/
import { computed, ref } from 'vue'
import { GtexTissueRecord } from '../../pbs/annonars/genes/base'
import DocsLink from '../DocsLink/DocsLink.vue'
import VegaPlot from '../VegaPlot/VegaPlot.vue'
import { TISSUE_DETAILED_LABELS, TISSUE_LABELS } from './constants'
const props = withDefaults(
defineProps<{
/** Gene symbol */
geneSymbol?: string
/** Expression records */
expressionRecords?: GtexTissueRecord[]
/** Ensembl gene ID */
ensemblGeneId?: string
}>(),
{
geneSymbol: '',
expressionRecords: () => [],
ensemblGeneId: ''
}
)
// -- interactive plot configuration -------------------------------------------
/** Sort order. */
enum SortOrder {
/** Sort alphabetically by label */
ALPHA = 'alphabetical',
/** Sort by TPM metric */
TPM = 'tpm'
}
/** Sort order configuration (currently static) */
const sortOrder = ref<SortOrder>(SortOrder.TPM)
// -- data for Vega plot -------------------------------------------------------
/** Interface for the plot data. */
interface VegaData {
/** Tissue label */
tissue: string
/** Detailed tissue label */
tissueDetailed: string
/** Lower quantile */
lower: number
/** First quartile */
q1: number
/** Median */
median: number
/** Third quartile */
q3: number
/** Upper quantile */
upper: number
}
/** Compute data values for Vega plot. */
const vegaData = computed<VegaData[]>(() => {
if (!props?.expressionRecords?.length) {
return []
}
const expressionRecords = Object.assign([], props?.expressionRecords)
if (sortOrder.value === SortOrder.ALPHA) {
expressionRecords.sort((a: GtexTissueRecord, b: GtexTissueRecord) => {
const aTissue = TISSUE_LABELS[a.tissue]
const bTissue = TISSUE_LABELS[b.tissue]
if (aTissue < bTissue) {
return -1
} else if (aTissue > bTissue) {
return 1
} else {
return 0
}
})
} else {
expressionRecords.sort((a: GtexTissueRecord, b: GtexTissueRecord) => {
if (a.tpms[2] < b.tpms[2]) {
return 1
} else if (a.tpms[2] > b.tpms[2]) {
return -1
} else {
return 0
}
})
}
return expressionRecords.map((record: GtexTissueRecord) => ({
tissue: TISSUE_LABELS[record.tissue],
tissueDetailed: TISSUE_DETAILED_LABELS[record.tissueDetailed],
lower: record.tpms[0],
q1: record.tpms[1],
median: record.tpms[2],
q3: record.tpms[3],
upper: record.tpms[4]
}))
})
/** The encoding for the Vega plot. */
const vegaEncoding = {
x: {
field: 'tissueDetailed',
type: 'nominal',
title: null,
axis: { labelAngle: 45 }
}
}
/** The layer definition for the Vega plot. */
const vegaLayer = [
{
mark: { type: 'rule', tooltip: { content: 'data' } },
encoding: {
y: {
field: 'lower',
type: 'quantitative',
scale: { zero: false },
title: 'TPM'
},
y2: { field: 'upper' }
}
},
{
mark: { type: 'bar', size: 14, tooltip: { content: 'data' } },
encoding: {
y: { field: 'q1', type: 'quantitative' },
y2: { field: 'q3' },
color: { field: 'tissue', type: 'nominal', legend: null }
}
},
{
mark: {
type: 'tick',
color: 'white',
size: 14,
tooltip: { content: 'data' }
},
encoding: {
y: { field: 'median', type: 'quantitative' }
}
}
]
</script>

<template>
<!-- no ENSG => display loader -->
<template v-if="!ensemblGeneId?.length">
<v-skeleton-loader class="mt-3 mx-auto border" type="image,button" />
</template>

<!-- otherwise, display actual card -->
<template v-else>
<v-card class="mt-3">
<v-card-title class="pb-0 pr-2">
Expression
<DocsLink anchor="expression" />
</v-card-title>
<v-card-subtitle class="text-overline">
Tissue-Specific Gene Expression from GTeX
</v-card-subtitle>
<v-card-text class="pt-3 pb-0">
<v-sheet color="background" class="pa-2">
<VegaPlot
data-name="expression"
:data-values="vegaData"
:encoding="vegaEncoding"
:layer="vegaLayer"
:mark="false"
:height="300"
:width="1200"
background="#eeeeee"
renderer="svg"
/>
</v-sheet>
</v-card-text>
<v-card-actions>
<v-btn
id="expression-card-gtex-portal"
:href="`https://gtexportal.org/home/gene/${ensemblGeneId ?? ''}`"
target="_blank"
prepend-icon="mdi-launch"
>
GTeX Portal
</v-btn>
</v-card-actions>
</v-card>
</template>
</template>
./constants
Loading

0 comments on commit b491224

Please sign in to comment.