diff --git a/website/src/components/views/analyzeSingleVariant/CovidSingleVariantReactPage.tsx b/website/src/components/views/analyzeSingleVariant/CovidSingleVariantReactPage.tsx index 139c6148b..dfc1cebc2 100644 --- a/website/src/components/views/analyzeSingleVariant/CovidSingleVariantReactPage.tsx +++ b/website/src/components/views/analyzeSingleVariant/CovidSingleVariantReactPage.tsx @@ -1,3 +1,4 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; import { type FC, useMemo } from 'react'; import { CollectionsList } from './CollectionsList.tsx'; @@ -15,9 +16,14 @@ import { usePageState } from '../usePageState.ts'; export type CovidSingleVariantReactPageProps = { organismsConfig: OrganismsConfig; isStaging: boolean; + mutationAnnotations?: MutationAnnotations; }; -export const CovidSingleVariantReactPage: FC = ({ organismsConfig, isStaging }) => { +export const CovidSingleVariantReactPage: FC = ({ + organismsConfig, + isStaging, + mutationAnnotations, +}) => { const organismViewKey: OrganismViewKey = 'covid.singleVariantView'; const view = useMemo(() => new Routing(organismsConfig).getOrganismView(organismViewKey), [organismsConfig]); @@ -49,6 +55,7 @@ export const CovidSingleVariantReactPage: FC = view={view} downloadLinks={downloadLinks} organismsConfig={organismsConfig} + mutationAnnotations={mutationAnnotations} filters={ <> , typeof Organisms.covid>; organismsConfig: OrganismsConfig; isStaging: boolean; + mutationAnnotations?: MutationAnnotations; }; export const GenericAnalyseSingleVariantReactPage: FC = ({ organism, organismsConfig, isStaging, + mutationAnnotations, }) => { const organismViewKey = `${organism}.${singleVariantViewKey}` satisfies OrganismViewKey; const view = useMemo( @@ -57,6 +60,7 @@ export const GenericAnalyseSingleVariantReactPage: FC @@ -21,6 +24,7 @@ const view = ServerSide.routing.getOrganismView(`${organism}.${singleVariantView organism={organism} organismsConfig={getDashboardsConfig().dashboards.organisms} isStaging={isStaging()} + mutationAnnotations={mutationAnnotations} client:only='react' /> diff --git a/website/src/components/views/compareSideBySide/GenericCompareSideBySidePage.astro b/website/src/components/views/compareSideBySide/GenericCompareSideBySidePage.astro index e6216e72e..dd94158bf 100644 --- a/website/src/components/views/compareSideBySide/GenericCompareSideBySidePage.astro +++ b/website/src/components/views/compareSideBySide/GenericCompareSideBySidePage.astro @@ -2,6 +2,7 @@ import { GenericCompareSideBySideReactPage } from './GenericCompareSideBySideReactPage'; import { isStaging, getDashboardsConfig } from '../../../config'; import BaseLayout from '../../../layouts/base/BaseLayout.astro'; +import { fetchOrganismMutationAnnotations } from '../../../util/fetchOrganismMutationAnnotations'; import { type OrganismWithViewKey } from '../../../views/routing'; import { ServerSide } from '../../../views/serverSideRouting'; import { compareSideBySideViewKey } from '../../../views/viewKeys'; @@ -15,6 +16,8 @@ const { organism, hideMutationComponents } = Astro.props; const view = ServerSide.routing.getOrganismView(`${organism}.${compareSideBySideViewKey}`); const organismsConfig = getDashboardsConfig().dashboards.organisms; + +const mutationAnnotations = await fetchOrganismMutationAnnotations(view.organismConstants, organism); --- @@ -23,6 +26,7 @@ const organismsConfig = getDashboardsConfig().dashboards.organisms; hideMutationComponents={hideMutationComponents} organismsConfig={organismsConfig} isStaging={isStaging()} + mutationAnnotations={mutationAnnotations} client:only='react' /> diff --git a/website/src/components/views/compareSideBySide/GenericCompareSideBySideReactPage.tsx b/website/src/components/views/compareSideBySide/GenericCompareSideBySideReactPage.tsx index 9d58f2243..13d11342f 100644 --- a/website/src/components/views/compareSideBySide/GenericCompareSideBySideReactPage.tsx +++ b/website/src/components/views/compareSideBySide/GenericCompareSideBySideReactPage.tsx @@ -1,3 +1,4 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; import { type FC, useMemo, useState, useEffect } from 'react'; import { GenericCompareSideBySideDataDisplay } from './GenericCompareSideBySideDataDisplay.tsx'; @@ -15,6 +16,7 @@ export type GenericCompareSideBySideReactPageProps = { hideMutationComponents?: boolean; organismsConfig: OrganismsConfig; isStaging: boolean; + mutationAnnotations?: MutationAnnotations; }; export const GenericCompareSideBySideReactPage: FC = ({ @@ -22,6 +24,7 @@ export const GenericCompareSideBySideReactPage: FC { const organismViewKey = `${organism}.${compareSideBySideViewKey}` satisfies OrganismViewKey; @@ -45,6 +48,7 @@ export const GenericCompareSideBySideReactPage: FC
diff --git a/website/src/components/views/compareToBaseline/GenericCompareToBaselinePage.astro b/website/src/components/views/compareToBaseline/GenericCompareToBaselinePage.astro index 650f35d58..96868e346 100644 --- a/website/src/components/views/compareToBaseline/GenericCompareToBaselinePage.astro +++ b/website/src/components/views/compareToBaseline/GenericCompareToBaselinePage.astro @@ -2,6 +2,7 @@ import { GenericCompareToBaselineReactPage } from './GenericCompareToBaselineReactPage'; import { isStaging, getDashboardsConfig } from '../../../config'; import BaseLayout from '../../../layouts/base/BaseLayout.astro'; +import { fetchOrganismMutationAnnotations } from '../../../util/fetchOrganismMutationAnnotations'; import { type OrganismWithViewKey } from '../../../views/routing'; import { ServerSide } from '../../../views/serverSideRouting'; import { compareToBaselineViewKey } from '../../../views/viewKeys'; @@ -13,6 +14,8 @@ interface Props { const { organism } = Astro.props; const view = ServerSide.routing.getOrganismView(`${organism}.${compareToBaselineViewKey}`); + +const mutationAnnotations = await fetchOrganismMutationAnnotations(view.organismConstants, organism); --- @@ -20,6 +23,7 @@ const view = ServerSide.routing.getOrganismView(`${organism}.${compareToBaseline organism={organism} organismsConfig={getDashboardsConfig().dashboards.organisms} isStaging={isStaging()} + mutationAnnotations={mutationAnnotations} client:only='react' /> diff --git a/website/src/components/views/compareToBaseline/GenericCompareToBaselineReactPage.tsx b/website/src/components/views/compareToBaseline/GenericCompareToBaselineReactPage.tsx index c750149bd..9beb450b7 100644 --- a/website/src/components/views/compareToBaseline/GenericCompareToBaselineReactPage.tsx +++ b/website/src/components/views/compareToBaseline/GenericCompareToBaselineReactPage.tsx @@ -1,3 +1,4 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; import { type FC, useMemo } from 'react'; import { GenericCompareToBaselineDataDisplay } from './GenericCompareToBaselineDataDisplay'; @@ -13,12 +14,14 @@ export type GenericCompareToBaselineReactPageProps = { organism: OrganismWithViewKey; organismsConfig: OrganismsConfig; isStaging: boolean; + mutationAnnotations?: MutationAnnotations; }; export const GenericCompareToBaselineReactPage: FC = ({ organism, organismsConfig, isStaging, + mutationAnnotations, }) => { const organismViewKey = `${organism}.${compareToBaselineViewKey}` satisfies OrganismViewKey; const view = useMemo( @@ -44,6 +47,7 @@ export const GenericCompareToBaselineReactPage: FC @@ -20,6 +23,7 @@ const view = ServerSide.routing.getOrganismView(`${organism}.${compareVariantsVi organism={organism} organismsConfig={getDashboardsConfig().dashboards.organisms} isStaging={isStaging()} + mutationAnnotations={mutationAnnotations} client:only='react' /> diff --git a/website/src/components/views/compareVariants/GenericCompareVariantsReactPage.tsx b/website/src/components/views/compareVariants/GenericCompareVariantsReactPage.tsx index 737f16139..3c16ff38d 100644 --- a/website/src/components/views/compareVariants/GenericCompareVariantsReactPage.tsx +++ b/website/src/components/views/compareVariants/GenericCompareVariantsReactPage.tsx @@ -1,3 +1,4 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; import { type FC, useMemo } from 'react'; import { GenericCompareVariantsDataDisplay } from './GenericCompareVariantsDataDisplay'; @@ -13,12 +14,14 @@ export type GenericCompareVariantsReactPageProps = { organism: OrganismWithViewKey; organismsConfig: OrganismsConfig; isStaging: boolean; + mutationAnnotations?: MutationAnnotations; }; export const GenericCompareVariantsReactPage: FC = ({ organism, organismsConfig, isStaging, + mutationAnnotations, }) => { const organismViewKey = `${organism}.${compareVariantsViewKey}` satisfies OrganismViewKey; const view = useMemo( @@ -44,6 +47,7 @@ export const GenericCompareVariantsReactPage: FC @@ -20,6 +23,7 @@ const view = ServerSide.routing.getOrganismView(`${organism}.${sequencingEfforts organism={organism} organismsConfig={getDashboardsConfig().dashboards.organisms} isStaging={isStaging()} + mutationAnnotations={mutationAnnotations} client:only='react' /> diff --git a/website/src/components/views/sequencingEfforts/GenericSequencingEffortsReactPage.tsx b/website/src/components/views/sequencingEfforts/GenericSequencingEffortsReactPage.tsx index 2f7eb91d3..f46bf3b14 100644 --- a/website/src/components/views/sequencingEfforts/GenericSequencingEffortsReactPage.tsx +++ b/website/src/components/views/sequencingEfforts/GenericSequencingEffortsReactPage.tsx @@ -1,3 +1,4 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; import { type FC, useMemo } from 'react'; import { GenericSequencingEffortsDataDisplay } from './GenericSequencingEffortsDataDisplay'; @@ -12,12 +13,14 @@ export type GenericSequencingEffortsReactPageProps = { organism: OrganismWithViewKey; organismsConfig: OrganismsConfig; isStaging: boolean; + mutationAnnotations?: MutationAnnotations; }; export const GenericSequencingEffortsReactPage: FC = ({ organism, organismsConfig, isStaging, + mutationAnnotations, }) => { const organismViewKey = `${organism}.${sequencingEffortsViewKey}` satisfies OrganismViewKey; const view = useMemo( @@ -42,6 +45,7 @@ export const GenericSequencingEffortsReactPage: FC = {}; setConfigs.forEach((setConfig, i) => { const filterVariants = collections[i].variants.filter((v) => v.type === 'filterObject'); - const allMutations = filterVariants.flatMap((v) => v.filterObject.aminoAcidMutations ?? []); - - displayMutationsBySet[setConfig.name] = allMutations; - - mutationAnnotations.push({ - name: setConfig.name, - symbol: setConfig.annotationSymbol, - description: setConfig.description, - aminoAcidMutations: filterVariants.flatMap((variant) => - (variant.filterObject.aminoAcidMutations ?? []).map((mutation) => ({ - mutation, - name: variant.name, - })), - ), - }); + displayMutationsBySet[setConfig.name] = filterVariants.flatMap((v) => v.filterObject.aminoAcidMutations ?? []); }); return { mutationAnnotations, displayMutationsBySet }; diff --git a/website/src/components/views/wasap/wasapPageConfig.ts b/website/src/components/views/wasap/wasapPageConfig.ts index 74575de67..d4e05c7f0 100644 --- a/website/src/components/views/wasap/wasapPageConfig.ts +++ b/website/src/components/views/wasap/wasapPageConfig.ts @@ -1,6 +1,7 @@ import type { DateRangeOption, SequenceType, TemporalGranularity } from '@genspectrum/dashboard-components/util'; import type { Organism } from '../../../types/Organism.ts'; +import type { ResistanceMutationCollectionConfig } from '../../../util/resistanceMutations'; export const SEQUENCE_TYPE = { nucleotide: 'nucleotide', @@ -305,13 +306,3 @@ export type WasapFilter = { base: WasapBaseFilter; analysis: WasapAnalysisFilter; }; - -/** - * Resistance mutations defined in a collection, which is specified via the collection ID. - */ -export type ResistanceMutationCollectionConfig = { - collectionId: number; - name: string; - description: string; - annotationSymbol: string; -}; diff --git a/website/src/layouts/OrganismPage/OrganismViewPageLayout.tsx b/website/src/layouts/OrganismPage/OrganismViewPageLayout.tsx index ba77e9674..1e7cba6ee 100644 --- a/website/src/layouts/OrganismPage/OrganismViewPageLayout.tsx +++ b/website/src/layouts/OrganismPage/OrganismViewPageLayout.tsx @@ -1,3 +1,4 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; import type { FC, PropsWithChildren } from 'react'; import { type DownloadLink } from './AccessionsDownloadButton.tsx'; @@ -10,12 +11,14 @@ export type OrganismViewPageLayoutProps = PropsWithChildren<{ view: View>; downloadLinks: DownloadLink[]; lapisUrl: string; + mutationAnnotations?: MutationAnnotations; }>; export const OrganismViewPageLayout: FC = ({ view, downloadLinks, lapisUrl, + mutationAnnotations, children, }) => { return ( @@ -26,7 +29,10 @@ export const OrganismViewPageLayout: FC = ({ lapisUrl={lapisUrl} accessionDownloadFields={view.organismConstants.accessionDownloadFields} > - + {children} diff --git a/website/src/layouts/OrganismPage/SingleVariantOrganismPageLayout.tsx b/website/src/layouts/OrganismPage/SingleVariantOrganismPageLayout.tsx index ceebf3a44..ca2b33c96 100644 --- a/website/src/layouts/OrganismPage/SingleVariantOrganismPageLayout.tsx +++ b/website/src/layouts/OrganismPage/SingleVariantOrganismPageLayout.tsx @@ -1,3 +1,4 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; import type { FC, ReactNode } from 'react'; import { type DownloadLink } from './AccessionsDownloadButton.tsx'; @@ -13,6 +14,7 @@ export type SingleVariantOrganismPageLayoutProps = { organismsConfig: OrganismsConfig; filters: ReactNode; dataDisplay: ReactNode; + mutationAnnotations?: MutationAnnotations; }; export const SingleVariantOrganismPageLayout: FC = ({ @@ -21,12 +23,14 @@ export const SingleVariantOrganismPageLayout: FC { return (
{filters}
diff --git a/website/src/pages/covid/single-variant.astro b/website/src/pages/covid/single-variant.astro index fa95aaefb..1cc751cdf 100644 --- a/website/src/pages/covid/single-variant.astro +++ b/website/src/pages/covid/single-variant.astro @@ -2,15 +2,19 @@ import { CovidSingleVariantReactPage } from '../../components/views/analyzeSingleVariant/CovidSingleVariantReactPage'; import { isStaging, getDashboardsConfig } from '../../config'; import BaseLayout from '../../layouts/base/BaseLayout.astro'; +import { fetchOrganismMutationAnnotations } from '../../util/fetchOrganismMutationAnnotations'; import { ServerSide } from '../../views/serverSideRouting'; const view = ServerSide.routing.getOrganismView('covid.singleVariantView'); + +const mutationAnnotations = await fetchOrganismMutationAnnotations(view.organismConstants, 'covid'); --- diff --git a/website/src/types/dbIdSpace.ts b/website/src/types/dbIdSpace.ts index be1828a16..019943a03 100644 --- a/website/src/types/dbIdSpace.ts +++ b/website/src/types/dbIdSpace.ts @@ -6,6 +6,17 @@ export const dbIdSpaces = { export type DbIdSpace = (typeof dbIdSpaces)[keyof typeof dbIdSpaces]; +export function byEnv(env: DbIdSpace, vars: { prod: T; staging: T; local: T }): T { + switch (env) { + case dbIdSpaces.prod: + return vars.prod; + case dbIdSpaces.staging: + return vars.staging; + case dbIdSpaces.local: + return vars.local; + } +} + export function getDbIdSpace(): DbIdSpace { const envValue = process.env.DB_ID_SPACE ?? import.meta.env.DB_ID_SPACE; if (envValue) { diff --git a/website/src/types/wastewaterConfig.ts b/website/src/types/wastewaterConfig.ts index d11cde4c6..ec1928521 100644 --- a/website/src/types/wastewaterConfig.ts +++ b/website/src/types/wastewaterConfig.ts @@ -1,19 +1,12 @@ import type { MutationAnnotation } from '@genspectrum/dashboard-components/util'; -import { getDbIdSpace, dbIdSpaces, type DbIdSpace } from './dbIdSpace'; -import type { ResistanceMutationCollectionConfig } from '../components/views/wasap/wasapPageConfig'; +import { byEnv, dbIdSpaces, getDbIdSpace, type DbIdSpace } from './dbIdSpace'; import { VARIANT_TIME_FRAME, type WasapPageConfig } from '../components/views/wasap/wasapPageConfig'; - -function byEnv(env: DbIdSpace, vars: { prod: T; staging: T; local: T }): T { - switch (env) { - case dbIdSpaces.prod: - return vars.prod; - case dbIdSpaces.staging: - return vars.staging; - case dbIdSpaces.local: - return vars.local; - } -} +import { + buildCovidResistanceMutationCollections, + buildRsvAResistanceMutationCollections, + buildRsvBResistanceMutationCollections, +} from '../util/resistanceMutations'; export const wastewaterOrganisms = { covid: 'covid', @@ -45,29 +38,7 @@ function buildWastewaterOrganismConfigs(env: DbIdSpace): RecordStanford Coronavirus Antiviral & Resistance database (last updated on 21 August 2024).', - }, - { - collectionId: byEnv(env, { prod: 5, staging: 2, local: 2 }), - name: 'RdRp', - annotationSymbol: 'r', - description: - 'SARS-CoV-2 RNA-dependent RNA polymerase (RdRP) inhibitor resistance mutation as per Stanford Coronavirus Antiviral & Resistance database (last updated on 21 August 2024).', - }, - { - collectionId: byEnv(env, { prod: 6, staging: 3, local: 3 }), - name: 'Spike', - annotationSymbol: 's', - description: - 'SARS-CoV-2 Spike monoclonal antibody (mAb) resistance mutation as per Stanford Coronavirus Antiviral & Resistance database (last updated on 21 August 2024).', - }, - ] satisfies ResistanceMutationCollectionConfig[], + resistanceMutationCollections: buildCovidResistanceMutationCollections(env), lapisBaseUrl: 'https://lapis.wasap.genspectrum.org/covid', samplingDateField: 'samplingDate', locationNameField: 'locationName', @@ -157,22 +128,7 @@ function buildWastewaterOrganismConfigs(env: DbIdSpace): RecordViralZone.', - }, - { - collectionId: byEnv(env, { prod: 4984, staging: 5, local: 5 }), - name: 'Palivizumab', - annotationSymbol: 'p', - description: - 'RSV-A F protein resistance mutations against Palivizumab as per ViralZone.', - }, - ] satisfies ResistanceMutationCollectionConfig[], + resistanceMutationCollections: buildRsvAResistanceMutationCollections(env), filterDefaults: { manual: { mode: 'manual', @@ -233,22 +189,7 @@ function buildWastewaterOrganismConfigs(env: DbIdSpace): RecordViralZone.', - }, - { - collectionId: byEnv(env, { prod: 4986, staging: 7, local: 7 }), - name: 'Palivizumab', - annotationSymbol: 'p', - description: - 'RSV-B F protein resistance mutations against Palivizumab as per ViralZone.', - }, - ] satisfies ResistanceMutationCollectionConfig[], + resistanceMutationCollections: buildRsvBResistanceMutationCollections(env), filterDefaults: { manual: { mode: 'manual', diff --git a/website/src/util/fetchOrganismMutationAnnotations.ts b/website/src/util/fetchOrganismMutationAnnotations.ts new file mode 100644 index 000000000..51beee493 --- /dev/null +++ b/website/src/util/fetchOrganismMutationAnnotations.ts @@ -0,0 +1,30 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; + +import { BackendService } from '../backendApi/backendService.ts'; +import { getBackendHost } from '../config'; +import { getInstanceLogger } from '../logger.ts'; +import { getErrorLogMessage } from './getErrorLogMessage'; +import { fetchMutationAnnotationsFromCollections } from './resistanceMutations'; +import { getDbIdSpace } from '../types/dbIdSpace'; +import type { OrganismConstants } from '../views/OrganismConstants'; + +const logger = getInstanceLogger('fetchOrganismMutationAnnotations'); + +export async function fetchOrganismMutationAnnotations( + constants: OrganismConstants, + organism: string, +): Promise { + const buildCollections = constants.buildResistanceMutationCollections; + if (!buildCollections) { + return undefined; + } + try { + return await fetchMutationAnnotationsFromCollections( + buildCollections(getDbIdSpace()), + new BackendService(getBackendHost()), + ); + } catch (error) { + logger.error(`Failed to fetch resistance mutation annotations for ${organism}: ${getErrorLogMessage(error)}`); + return undefined; + } +} diff --git a/website/src/util/resistanceMutations.spec.ts b/website/src/util/resistanceMutations.spec.ts new file mode 100644 index 000000000..01549c8b4 --- /dev/null +++ b/website/src/util/resistanceMutations.spec.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import { buildMutationAnnotations } from './resistanceMutations.ts'; +import type { Collection } from '../types/Collection.ts'; + +describe('buildMutationAnnotations', () => { + it('returns empty array for empty inputs', () => { + expect(buildMutationAnnotations([], [])).toEqual([]); + }); + + it('maps annotationSymbol to symbol and preserves name and description', () => { + const config = { collectionId: 1, name: 'Spike', description: 'spike desc', annotationSymbol: 's' }; + const collection = { + id: 1, + name: 'Spike', + variants: [ + { + type: 'filterObject', + name: 'v1', + filterObject: { aminoAcidMutations: ['S:E484K'] }, + }, + ], + } as unknown as Collection; + + const [annotation] = buildMutationAnnotations([config], [collection]); + + expect(annotation.name).toBe('Spike'); + expect(annotation.symbol).toBe('s'); + expect(annotation.description).toBe('spike desc'); + }); + + it('excludes query-type variants', () => { + const config = { collectionId: 1, name: 'Set', description: '', annotationSymbol: 'x' }; + const collection = { + id: 1, + name: 'Set', + variants: [ + { type: 'query', name: 'ignored', countQuery: 'q' }, + { type: 'filterObject', name: 'included', filterObject: { aminoAcidMutations: ['S:E484K'] } }, + ], + } as unknown as Collection; + + const [annotation] = buildMutationAnnotations([config], [collection]); + + expect(annotation.aminoAcidMutations).toEqual([{ mutation: 'S:E484K', name: 'included' }]); + }); + + it('treats missing aminoAcidMutations as empty', () => { + const config = { collectionId: 1, name: 'Set', description: '', annotationSymbol: 'x' }; + const collection = { + id: 1, + name: 'Set', + variants: [ + { type: 'filterObject', name: 'no-mutations', filterObject: {} }, + { type: 'filterObject', name: 'has-mutation', filterObject: { aminoAcidMutations: ['S:N501Y'] } }, + ], + } as unknown as Collection; + + const [annotation] = buildMutationAnnotations([config], [collection]); + + expect(annotation.aminoAcidMutations).toEqual([{ mutation: 'S:N501Y', name: 'has-mutation' }]); + }); + + it('builds one annotation per config, preserving order', () => { + const configs = [ + { collectionId: 1, name: '3CLpro', description: 'protease', annotationSymbol: 'c' }, + { collectionId: 2, name: 'Spike', description: 'spike', annotationSymbol: 's' }, + ]; + const collections = [ + { id: 1, name: '3CLpro', variants: [] } as unknown as Collection, + { id: 2, name: 'Spike', variants: [] } as unknown as Collection, + ]; + + const annotations = buildMutationAnnotations(configs, collections); + + expect(annotations).toHaveLength(2); + expect(annotations[0].name).toBe('3CLpro'); + expect(annotations[1].name).toBe('Spike'); + }); +}); diff --git a/website/src/util/resistanceMutations.ts b/website/src/util/resistanceMutations.ts new file mode 100644 index 000000000..e937ba611 --- /dev/null +++ b/website/src/util/resistanceMutations.ts @@ -0,0 +1,106 @@ +import type { MutationAnnotations } from '@genspectrum/dashboard-components/util'; + +import type { BackendService } from '../backendApi/backendService'; +import type { Collection } from '../types/Collection'; +import { byEnv, type DbIdSpace } from '../types/dbIdSpace'; + +export type ResistanceMutationCollectionConfig = { + collectionId: number; + name: string; + description: string; + annotationSymbol: string; +}; + +export function buildMutationAnnotations( + setConfigs: ResistanceMutationCollectionConfig[], + collections: Collection[], +): MutationAnnotations { + return setConfigs.map((setConfig, i) => { + const filterVariants = collections[i].variants.filter((v) => v.type === 'filterObject'); + return { + name: setConfig.name, + symbol: setConfig.annotationSymbol, + description: setConfig.description, + aminoAcidMutations: filterVariants.flatMap((variant) => + (variant.filterObject.aminoAcidMutations ?? []).map((mutation) => ({ + mutation, + name: variant.name, + })), + ), + }; + }); +} + +export async function fetchMutationAnnotationsFromCollections( + configs: ResistanceMutationCollectionConfig[], + backendService: BackendService, +): Promise { + const collections = await Promise.all( + configs.map((c) => backendService.getCollection({ id: String(c.collectionId) })), + ); + return buildMutationAnnotations(configs, collections); +} + +export function buildCovidResistanceMutationCollections(env: DbIdSpace): ResistanceMutationCollectionConfig[] { + return [ + { + collectionId: byEnv(env, { prod: 4, staging: 1, local: 1 }), + name: '3CLpro', + annotationSymbol: 'c', + description: + 'SARS-CoV-2 3C-like protease (3CLpro, or Mpro for Main protease) inhibitor resistance mutation as per Stanford Coronavirus Antiviral & Resistance database (last updated on 21 August 2024).', + }, + { + collectionId: byEnv(env, { prod: 5, staging: 2, local: 2 }), + name: 'RdRp', + annotationSymbol: 'r', + description: + 'SARS-CoV-2 RNA-dependent RNA polymerase (RdRP) inhibitor resistance mutation as per Stanford Coronavirus Antiviral & Resistance database (last updated on 21 August 2024).', + }, + { + collectionId: byEnv(env, { prod: 6, staging: 3, local: 3 }), + name: 'Spike', + annotationSymbol: 's', + description: + 'SARS-CoV-2 Spike monoclonal antibody (mAb) resistance mutation as per Stanford Coronavirus Antiviral & Resistance database (last updated on 21 August 2024).', + }, + ]; +} + +export function buildRsvAResistanceMutationCollections(env: DbIdSpace): ResistanceMutationCollectionConfig[] { + return [ + { + collectionId: byEnv(env, { prod: 4983, staging: 4, local: 4 }), + name: 'Nirsevimab', + annotationSymbol: 'n', + description: + 'RSV-A F protein resistance mutations against Nirsevimab as per ViralZone.', + }, + { + collectionId: byEnv(env, { prod: 4984, staging: 5, local: 5 }), + name: 'Palivizumab', + annotationSymbol: 'p', + description: + 'RSV-A F protein resistance mutations against Palivizumab as per ViralZone.', + }, + ]; +} + +export function buildRsvBResistanceMutationCollections(env: DbIdSpace): ResistanceMutationCollectionConfig[] { + return [ + { + collectionId: byEnv(env, { prod: 4985, staging: 6, local: 6 }), + name: 'Nirsevimab', + annotationSymbol: 'n', + description: + 'RSV-B F protein resistance mutations against Nirsevimab as per ViralZone.', + }, + { + collectionId: byEnv(env, { prod: 4986, staging: 7, local: 7 }), + name: 'Palivizumab', + annotationSymbol: 'p', + description: + 'RSV-B F protein resistance mutations against Palivizumab as per ViralZone.', + }, + ]; +} diff --git a/website/src/views/OrganismConstants.ts b/website/src/views/OrganismConstants.ts index b6c349517..6458932e1 100644 --- a/website/src/views/OrganismConstants.ts +++ b/website/src/views/OrganismConstants.ts @@ -15,6 +15,8 @@ import type { BaselineFilterConfig } from '../components/pageStateSelectors/Base import type { LineageFilterConfig } from '../components/pageStateSelectors/LineageFilterInput.tsx'; import type { Organism } from '../types/Organism.ts'; import type { DataOrigin } from '../types/dataOrigins.ts'; +import type { DbIdSpace } from '../types/dbIdSpace.ts'; +import type { ResistanceMutationCollectionConfig } from '../util/resistanceMutations.ts'; type AggregatedVisualizations = { sequencingEfforts: GsAggregatedConfig[]; @@ -36,6 +38,7 @@ export interface OrganismConstants { readonly lineageFilters: LineageFilterConfig[]; readonly predefinedVariants?: VariantFilter[]; readonly mutationAnnotations?: MutationAnnotation[]; + readonly buildResistanceMutationCollections?: (env: DbIdSpace) => ResistanceMutationCollectionConfig[]; } export const ComponentHeight = { diff --git a/website/src/views/covid.ts b/website/src/views/covid.ts index be5df708e..af030249d 100644 --- a/website/src/views/covid.ts +++ b/website/src/views/covid.ts @@ -1,4 +1,4 @@ -import { dateRangeOptionPresets, type MutationAnnotation, views } from '@genspectrum/dashboard-components/util'; +import { dateRangeOptionPresets, views } from '@genspectrum/dashboard-components/util'; import { getIntegerFromSearch, @@ -29,19 +29,20 @@ import { makeDatasetAndVariantData, } from './View.ts'; import { compareSideBySideViewConstants, singleVariantViewConstants } from './ViewConstants.ts'; +import { buildCovidResistanceMutationCollections } from '../util/resistanceMutations'; import { CompareSideBySideStateHandler } from './pageStateHandlers/CompareSideBySidePageStateHandler.ts'; import { type PageStateHandler } from './pageStateHandlers/PageStateHandler.ts'; +import { SingleVariantPageStateHandler } from './pageStateHandlers/SingleVariantPageStateHandler.ts'; import { setSearchFromDateFilters } from './pageStateHandlers/dateFilterFromToUrl.ts'; +import { setSearchFromLocationFilters } from './pageStateHandlers/locationFilterFromToUrl.ts'; +import { setSearchFromTextFilters } from './pageStateHandlers/textFilterFromToUrl.ts'; +import { advancedQueryUrlParam } from '../components/genspectrum/advancedQueryUrlParamConstants.ts'; +import type { BaselineFilterConfig } from '../components/pageStateSelectors/BaselineSelector.tsx'; import type { LineageFilterConfig } from '../components/pageStateSelectors/LineageFilterInput.tsx'; import { Organisms } from '../types/Organism.ts'; import { type DataOrigin, dataOrigins } from '../types/dataOrigins.ts'; -import { SingleVariantPageStateHandler } from './pageStateHandlers/SingleVariantPageStateHandler.ts'; -import { setSearchFromLocationFilters } from './pageStateHandlers/locationFilterFromToUrl.ts'; -import type { BaselineFilterConfig } from '../components/pageStateSelectors/BaselineSelector.tsx'; import { ALL_TIMES_LABEL, defaultDateRangeOption } from '../util/defaultDateRangeOption.ts'; import { formatUrl } from '../util/formatUrl.ts'; -import { setSearchFromTextFilters } from './pageStateHandlers/textFilterFromToUrl.ts'; -import { advancedQueryUrlParam } from '../components/genspectrum/advancedQueryUrlParamConstants.ts'; const earliestDate = '2020-01-06'; const hostField = 'host'; @@ -127,7 +128,7 @@ class CovidConstants implements OrganismConstants { public readonly additionalFilters: Record | undefined; public readonly dataOrigins: DataOrigin[] = [dataOrigins.nextstrain]; public readonly accessionDownloadFields = ['strain']; - public readonly mutationAnnotations: MutationAnnotation[] = []; + public readonly buildResistanceMutationCollections = buildCovidResistanceMutationCollections; public get aggregatedVisualizations() { const hosts = getHostsAggregatedVisualization(this); diff --git a/website/src/views/rsvA.ts b/website/src/views/rsvA.ts index 74bf2b7d9..8fd9600fd 100644 --- a/website/src/views/rsvA.ts +++ b/website/src/views/rsvA.ts @@ -1,4 +1,4 @@ -import { dateRangeOptionPresets, type MutationAnnotation } from '@genspectrum/dashboard-components/util'; +import { dateRangeOptionPresets } from '@genspectrum/dashboard-components/util'; import { type CompareSideBySideData, @@ -34,6 +34,7 @@ import { dataOrigins } from '../types/dataOrigins.ts'; import { CompareSideBySideStateHandler } from './pageStateHandlers/CompareSideBySidePageStateHandler.ts'; import type { BaselineFilterConfig } from '../components/pageStateSelectors/BaselineSelector.tsx'; import { fineGrainedDefaultDateRangeOptions } from '../util/defaultDateRangeOption.ts'; +import { buildRsvAResistanceMutationCollections } from '../util/resistanceMutations'; const earliestDate = '1956-01-01'; @@ -74,7 +75,7 @@ class RsvAConstants implements OrganismConstants { lineages: { [LINEAGE_FIELD_NAME]: 'A.D.1' }, }, ]; - public readonly mutationAnnotations: MutationAnnotation[] = []; + public readonly buildResistanceMutationCollections = buildRsvAResistanceMutationCollections; public get aggregatedVisualizations() { return getPathoplexusSequencingEffortsAggregatedVisualizations(this, { diff --git a/website/src/views/rsvB.ts b/website/src/views/rsvB.ts index e2819b913..fc35a6d86 100644 --- a/website/src/views/rsvB.ts +++ b/website/src/views/rsvB.ts @@ -1,4 +1,4 @@ -import { dateRangeOptionPresets, type MutationAnnotation } from '@genspectrum/dashboard-components/util'; +import { dateRangeOptionPresets } from '@genspectrum/dashboard-components/util'; import { type CompareSideBySideData, @@ -34,6 +34,7 @@ import { dataOrigins } from '../types/dataOrigins.ts'; import { CompareSideBySideStateHandler } from './pageStateHandlers/CompareSideBySidePageStateHandler.ts'; import type { BaselineFilterConfig } from '../components/pageStateSelectors/BaselineSelector.tsx'; import { fineGrainedDefaultDateRangeOptions } from '../util/defaultDateRangeOption.ts'; +import { buildRsvBResistanceMutationCollections } from '../util/resistanceMutations'; const earliestDate = '1956-01-01'; @@ -74,7 +75,7 @@ class RsvBConstants implements OrganismConstants { lineages: { [LINEAGE_FIELD_NAME]: 'B.D.4.1.1' }, }, ]; - public readonly mutationAnnotations: MutationAnnotation[] = []; + public readonly buildResistanceMutationCollections = buildRsvBResistanceMutationCollections; public get aggregatedVisualizations() { return getPathoplexusSequencingEffortsAggregatedVisualizations(this, { diff --git a/website/tests/resistanceMutationAnnotations.spec.ts b/website/tests/resistanceMutationAnnotations.spec.ts new file mode 100644 index 000000000..fc94b3dfe --- /dev/null +++ b/website/tests/resistanceMutationAnnotations.spec.ts @@ -0,0 +1,58 @@ +import { expect } from '@playwright/test'; + +import { test } from './e2e.fixture.ts'; +import { organismOptions } from './helpers/organisms.ts'; +import { Organisms } from '../src/types/Organism.ts'; + +test.describe('Resistance mutation annotations on regular dashboards', () => { + test.setTimeout(60_000); + + test('COVID single-variant page shows resistance mutation sets in the filter mutations panel', async ({ + singleVariantPage, + }) => { + const options = organismOptions[Organisms.covid]; + await singleVariantPage.goto(Organisms.covid); + await singleVariantPage.selectDateRange('All times'); + await singleVariantPage.selectVariant(options); + await singleVariantPage.submitFilters(); + + const panel = singleVariantPage.page.locator('gs-mutations-over-time').first(); + await panel.getByRole('button', { name: /filter mutations/i }).click(); + + await expect(panel.getByText('3CLpro')).toBeVisible(); + await expect(panel.getByText('RdRp')).toBeVisible(); + await expect(panel.getByText('Spike')).toBeVisible(); + }); + + test('RSV-A single-variant page shows resistance mutation sets in the filter mutations panel', async ({ + singleVariantPage, + }) => { + const options = organismOptions[Organisms.rsvA]; + await singleVariantPage.goto(Organisms.rsvA); + await singleVariantPage.selectDateRange('All times'); + await singleVariantPage.selectVariant(options); + await singleVariantPage.submitFilters(); + + const panel = singleVariantPage.page.locator('gs-mutations-over-time').first(); + await panel.getByRole('button', { name: /filter mutations/i }).click(); + + await expect(panel.getByText('Nirsevimab')).toBeVisible(); + await expect(panel.getByText('Palivizumab')).toBeVisible(); + }); + + test('RSV-B single-variant page shows resistance mutation sets in the filter mutations panel', async ({ + singleVariantPage, + }) => { + const options = organismOptions[Organisms.rsvB]; + await singleVariantPage.goto(Organisms.rsvB); + await singleVariantPage.selectDateRange('All times'); + await singleVariantPage.selectVariant(options); + await singleVariantPage.submitFilters(); + + const panel = singleVariantPage.page.locator('gs-mutations-over-time').first(); + await panel.getByRole('button', { name: /filter mutations/i }).click(); + + await expect(panel.getByText('Nirsevimab')).toBeVisible(); + await expect(panel.getByText('Palivizumab')).toBeVisible(); + }); +});