Skip to content

Commit

Permalink
feat(components): add mutation over time plot for wastewater / WISE
Browse files Browse the repository at this point in the history
  • Loading branch information
chaoran-chen committed Nov 15, 2024
1 parent 4eec1f5 commit 45d8674
Show file tree
Hide file tree
Showing 11 changed files with 4,561 additions and 3,274 deletions.
7,429 changes: 4,155 additions & 3,274 deletions components/package-lock.json

Large diffs are not rendered by default.

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 @@ -49,6 +50,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 @@ -138,6 +154,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,42 @@
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' },
locations: { control: 'object' },
},
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} locations={args.locations} />
</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',
locations: [],
},
};
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 { 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 { ErrorDisplay } from '../../components/error-display';
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;
locations: string[];
}

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), []); // TODO fetch and transform data, filter by locations

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

if (error !== null) {
return <ErrorDisplay error={error} />;
}

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

const locationMap = new Map<string, MutationOverTimeDataMap>();
for (const row of data) {
if (componentProps.locations.length > 0 && !componentProps.locations.includes(row.location)) {
continue;
}
if (!locationMap.has(row.location)) {
locationMap.set(row.location, new BaseMutationOverTimeDataMap());
}
const map = locationMap.get(row.location)!;
for (const mutation of row.mutations) {
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>
<InfoComponentCode componentName='mutations-over-time' params={originalComponentProps} lapisUrl={lapis} />
</Info>
);
};

function getDownloadData(data: MutationOverTimeDataPerLocation) {
// TODO
return [];
}
31 changes: 31 additions & 0 deletions components/src/query/queryWastewaterData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { parseDateStringToTemporal, TemporalClass, toTemporalClass } from '../utils/temporalClass';
import { FetchDetailsOperator } from '../operator/FetchDetailsOperator';
import { Substitution, SubstitutionClass } from '../utils/mutations';

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

export async function queryWastewaterData(lapis: string, signal?: AbortSignal): Promise<WastewaterData> {
const fetchData = new FetchDetailsOperator<Record<string, string | null | number>>({}, [
'date',
'location',
'mutations',
]);
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')),
mutations: transformMutations(JSON.parse(row.mutations 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,
}));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { Meta, StoryObj } from '@storybook/web-components';
import { html } from 'lit';

import './gs-wastewater-mutations-over-time';
import '../app';
import { withComponentDocs } from '../../../.storybook/ComponentDocsBlock';
import { WISE_LAPIS_URL } from '../../constants';
import { type WastewaterMutationsOverTimeProps } from '../../preact/wastewater/mutationsOverTime/wastewater-mutations-over-time';

const codeExample = String.raw`
<gs-wastewater-mutations-over-time
locations='[]'
width='100%'
height='700px'
></gs-wastewater-mutations-over-time>`;

const meta: Meta<Required<WastewaterMutationsOverTimeProps>> = {
title: 'Visualization/Wastewater mutations over time',
component: 'gs-wastewater-mutations-over-time',
argTypes: {
locations: { control: 'object' },
width: { control: 'text' },
height: { control: 'text' },
},
args: {
locations: [],
width: '100%',
height: '700px',
},
parameters: withComponentDocs({
componentDocs: {
opensShadowDom: true,
expectsChildren: false,
codeExample,
},
fetchMock: {},
}),
tags: ['autodocs'],
};

export default meta;

export const Default: StoryObj<Required<WastewaterMutationsOverTimeProps>> = {
render: (args) => html`
<gs-app lapis="${WISE_LAPIS_URL}">
<gs-wastewater-mutations-over-time
.views=${args.views}
.width=${args.width}
.height=${args.height}
></gs-wastewater-mutations-over-time>
</gs-app>
`,
};
Loading

0 comments on commit 45d8674

Please sign in to comment.