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

feat(components): add mutation over time plot for wastewater / WISE #504

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
4 changes: 4 additions & 0 deletions components/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@ export const NUCLEOTIDE_MUTATIONS_ENDPOINT = `${LAPIS_URL}/sample/nucleotideMuta
export const AMINO_ACID_MUTATIONS_ENDPOINT = `${LAPIS_URL}/sample/aminoAcidMutations`;
export const NUCLEOTIDE_INSERTIONS_ENDPOINT = `${LAPIS_URL}/sample/nucleotideInsertions`;
export const REFERENCE_GENOME_ENDPOINT = `${LAPIS_URL}/sample/referenceGenome`;

// WISE Wastewater
// This is a special instance for storing Swiss wastewater data generated by the WISE consortium
export const WISE_LAPIS_URL = 'https://lapis-wise.loculus.org/random';
17 changes: 17 additions & 0 deletions components/src/lapisApi/lapisApi.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { referenceGenomeResponse } from './ReferenceGenome';
import {
aggregatedResponse,
detailsResponse,
insertionsResponse,
type LapisBaseRequest,
lapisError,
Expand Down Expand Up @@ -51,6 +52,21 @@ export async function fetchAggregated(lapisUrl: string, body: LapisBaseRequest,
return aggregatedResponse.parse(await response.json());
}

export async function fetchDetails(lapisUrl: string, body: LapisBaseRequest, signal?: AbortSignal) {
const response = await fetch(detailsEndpoint(lapisUrl), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
signal,
});

await handleErrors(response, 'aggregated data');

return detailsResponse.parse(await response.json());
}

export async function fetchInsertions(
lapisUrl: string,
body: LapisBaseRequest,
Expand Down Expand Up @@ -163,6 +179,7 @@ const handleErrors = async (response: Response, requestedData: string) => {
};

export const aggregatedEndpoint = (lapisUrl: string) => `${lapisUrl}/sample/aggregated`;
export const detailsEndpoint = (lapisUrl: string) => `${lapisUrl}/sample/details`;
export const insertionsEndpoint = (lapisUrl: string, sequenceType: SequenceType) => {
return sequenceType === 'amino acid'
? `${lapisUrl}/sample/aminoAcidInsertions`
Expand Down
4 changes: 4 additions & 0 deletions components/src/lapisApi/lapisTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ export const aggregatedItem = z.object({ count: z.number() }).catchall(z.union([
export const aggregatedResponse = makeLapisResponse(z.array(aggregatedItem));
export type AggregatedItem = z.infer<typeof aggregatedItem>;

export const detailsItem = z.object({}).catchall(z.union([z.string(), z.number(), z.null()]));
export const detailsResponse = makeLapisResponse(z.array(detailsItem));
export type DetailsItem = z.infer<typeof detailsItem>;

function makeLapisResponse<T extends ZodTypeAny>(data: T) {
return z.object({
data,
Expand Down
27 changes: 27 additions & 0 deletions components/src/operator/FetchDetailsOperator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { type Dataset } from './Dataset';
import { type Operator } from './Operator';
import { fetchAggregated, fetchDetails } from '../lapisApi/lapisApi';
import { type AggregatedItem } from '../lapisApi/lapisTypes';
import { type LapisFilter } from '../types';

export class FetchDetailsOperator<Fields extends Record<string, unknown>> implements Operator<Fields> {
constructor(
private filter: LapisFilter,
private fields: string[] = [],
) {}

async evaluate(lapisUrl: string, signal?: AbortSignal): Promise<Dataset<Fields>> {
const detailsResponse = (
await fetchDetails(
lapisUrl,
{
...this.filter,
fields: this.fields,
},
signal,
)
).data;

return { content: detailsResponse as Fields[] };
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { type Meta, type StoryObj } from '@storybook/preact';

import { WastewaterMutationsOverTime, type WastewaterMutationsOverTimeProps } from './wastewater-mutations-over-time';
import { WISE_LAPIS_URL } from '../../../constants';
import referenceGenome from '../../../lapisApi/__mockData__/referenceGenome.json';
import { LapisUrlContext } from '../../LapisUrlContext';
import { ReferenceGenomeContext } from '../../ReferenceGenomeContext';

const meta: Meta<WastewaterMutationsOverTimeProps> = {
title: 'Visualization/Wastewater Mutation over time',
component: WastewaterMutationsOverTime,
argTypes: {
width: { control: 'text' },
height: { control: 'text' },
lapisFilter: { control: 'object' },
sequenceType: {
options: ['nucleotide', 'amino acid'],
control: { type: 'radio' },
},
},
parameters: {
fetchMock: {},
},
};

export default meta;

const Template = {
render: (args: WastewaterMutationsOverTimeProps) => (
<LapisUrlContext.Provider value={WISE_LAPIS_URL}>
<ReferenceGenomeContext.Provider value={referenceGenome}>
<WastewaterMutationsOverTime
width={args.width}
height={args.height}
lapisFilter={args.lapisFilter}
sequenceType={args.sequenceType}
/>
</ReferenceGenomeContext.Provider>
</LapisUrlContext.Provider>
),
};

// This test uses mock data: defaultMockData.ts (through mutationOverTimeWorker.mock.ts)
export const Default: StoryObj<WastewaterMutationsOverTimeProps> = {
...Template,
args: {
width: '100%',
height: '700px',
lapisFilter: {},
sequenceType: 'nucleotide',
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { type FunctionComponent } from 'preact';
import { type Dispatch, type StateUpdater, useContext, useState } from 'preact/hooks';

import { queryWastewaterData } from '../../../query/queryWastewaterData';
import { type LapisFilter, type SequenceType } from '../../../types';
import { LapisUrlContext } from '../../LapisUrlContext';
import { type ColorScale } from '../../components/color-scale-selector';
import { ColorScaleSelectorDropdown } from '../../components/color-scale-selector-dropdown';
import { CsvDownloadButton } from '../../components/csv-download-button';
import { ErrorBoundary } from '../../components/error-boundary';
import { Fullscreen } from '../../components/fullscreen';
import Info, { InfoComponentCode, InfoHeadline1, InfoParagraph } from '../../components/info';
import { LoadingDisplay } from '../../components/loading-display';
import { NoDataDisplay } from '../../components/no-data-display';
import { ResizeContainer } from '../../components/resize-container';
import Tabs from '../../components/tabs';
import {
BaseMutationOverTimeDataMap,
type MutationOverTimeDataMap,
} from '../../mutationsOverTime/MutationOverTimeData';
import MutationsOverTimeGrid from '../../mutationsOverTime/mutations-over-time-grid';
import { useQuery } from '../../useQuery';

export interface WastewaterMutationsOverTimeProps {
width: string;
height: string;
lapisFilter: LapisFilter;
sequenceType: SequenceType;
}

export const WastewaterMutationsOverTime: FunctionComponent<WastewaterMutationsOverTimeProps> = (componentProps) => {
const { width, height } = componentProps;
const size = { height, width };

return (
<ErrorBoundary size={size}>
<ResizeContainer size={size}>
<WastewaterMutationsOverTimeInner {...componentProps} />
</ResizeContainer>
</ErrorBoundary>
);
};

export const WastewaterMutationsOverTimeInner: FunctionComponent<WastewaterMutationsOverTimeProps> = (
componentProps,
) => {
const lapis = useContext(LapisUrlContext);

const { data, error, isLoading } = useQuery(() => queryWastewaterData(lapis, componentProps.lapisFilter), []);

if (isLoading) {
return <LoadingDisplay />;
}

if (error !== null) {
throw error;
}

if (data === null || data === undefined) {
return <NoDataDisplay />;
}

const locationMap = new Map<string, MutationOverTimeDataMap>();
for (const row of data) {
if (!locationMap.has(row.location)) {
locationMap.set(row.location, new BaseMutationOverTimeDataMap());
}
const map = locationMap.get(row.location)!;
for (const mutation of componentProps.sequenceType === 'nucleotide'
? row.nucleotideMutationFrequency
: row.aminoAcidMutationFrequency) {
map.set(mutation.mutation, row.date, { proportion: mutation.proportion, count: NaN, totalCount: NaN });
}
}
const mutationOverTimeDataPerLocation = [...locationMap.entries()].map(([location, data]) => ({ location, data }));

return (
<MutationsOverTimeTabs
mutationOverTimeDataPerLocation={mutationOverTimeDataPerLocation}
originalComponentProps={componentProps}
/>
);
};

type MutationOverTimeDataPerLocation = {
location: string;
data: MutationOverTimeDataMap;
}[];

type MutationOverTimeTabsProps = {
mutationOverTimeDataPerLocation: MutationOverTimeDataPerLocation;
originalComponentProps: WastewaterMutationsOverTimeProps;
};

const MutationsOverTimeTabs: FunctionComponent<MutationOverTimeTabsProps> = ({
mutationOverTimeDataPerLocation,
originalComponentProps,
}) => {
const [colorScale, setColorScale] = useState<ColorScale>({ min: 0, max: 1, color: 'indigo' });

const tabs = mutationOverTimeDataPerLocation.map(({ location, data }) => ({
title: location,
content: <MutationsOverTimeGrid data={data} colorScale={colorScale} />,
}));

const toolbar = (activeTab: string) => (
<Toolbar
activeTab={activeTab}
colorScale={colorScale}
setColorScale={setColorScale}
originalComponentProps={originalComponentProps}
data={mutationOverTimeDataPerLocation}
/>
);

return <Tabs tabs={tabs} toolbar={toolbar} />;
};

type ToolbarProps = {
activeTab: string;
colorScale: ColorScale;
setColorScale: Dispatch<StateUpdater<ColorScale>>;
originalComponentProps: WastewaterMutationsOverTimeProps;
data: MutationOverTimeDataPerLocation;
};

const Toolbar: FunctionComponent<ToolbarProps> = ({
activeTab,
colorScale,
setColorScale,
originalComponentProps,
data,
}) => {
return (
<>
{activeTab === 'Grid' && (
<ColorScaleSelectorDropdown colorScale={colorScale} setColorScale={setColorScale} />
)}
<CsvDownloadButton
className='mx-1 btn btn-xs'
getData={() => getDownloadData(data)}
filename='wastewater_mutations_over_time.csv'
/>
<WastewaterMutationsOverTimeInfo originalComponentProps={originalComponentProps} />
<Fullscreen />
</>
);
};

type WastewaterMutationsOverTimeInfoProps = {
originalComponentProps: WastewaterMutationsOverTimeProps;
};

const WastewaterMutationsOverTimeInfo: FunctionComponent<WastewaterMutationsOverTimeInfoProps> = ({
originalComponentProps,
}) => {
const lapis = useContext(LapisUrlContext);
return (
<Info>
<InfoHeadline1>Info for mutations over time</InfoHeadline1>
<InfoParagraph>TODO: Ask for text</InfoParagraph>
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't forget

<InfoComponentCode componentName='mutations-over-time' params={originalComponentProps} lapisUrl={lapis} />
</Info>
);
};

function getDownloadData(data: MutationOverTimeDataPerLocation) {
// TODO
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't forget

return [];
}
46 changes: 46 additions & 0 deletions components/src/query/queryWastewaterData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { parseDateStringToTemporal, type TemporalClass, toTemporalClass } from '../utils/temporalClass';
import { FetchDetailsOperator } from '../operator/FetchDetailsOperator';
import { type Substitution, SubstitutionClass } from '../utils/mutations';
import { LapisFilter } from '../types';

export type WastewaterData = {
location: string;
date: TemporalClass;
nucleotideMutationFrequency: { mutation: Substitution; proportion: number }[];
aminoAcidMutationFrequency: { mutation: Substitution; proportion: number }[];
}[];

export async function queryWastewaterData(
lapis: string,
lapisFilter: LapisFilter,
signal?: AbortSignal,
): Promise<WastewaterData> {
const fetchData = new FetchDetailsOperator<Record<string, string | null | number>>(lapisFilter, [
'date',
'location',
'reference',
'nucleotideMutationFrequency',
'aminoAcidMutationFrequency',
]);
const data = (await fetchData.evaluate(lapis, signal)).content;

return data.map((row) => ({
location: row.location as string,
date: toTemporalClass(parseDateStringToTemporal(row.date as string, 'day')),
nucleotideMutationFrequency:
row.nucleotideMutationFrequency !== null
? transformMutations(JSON.parse(row.nucleotideMutationFrequency as string))
: [],
aminoAcidMutationFrequency:
row.aminoAcidMutationFrequency !== null
? transformMutations(JSON.parse(row.aminoAcidMutationFrequency as string))
: [],
}));
}

function transformMutations(input: Record<string, number>): { mutation: Substitution; proportion: number }[] {
return Object.entries(input).map(([key, value]) => ({
mutation: SubstitutionClass.parse(key)!,
proportion: value,
}));
}
Loading
Loading