From 1bda5d4ad9878636b0c4a9c4ea0d7f68d7b8bc78 Mon Sep 17 00:00:00 2001 From: "Jonas Kellerer (TNG)" Date: Tue, 2 Jul 2024 17:22:33 +0200 Subject: [PATCH] feat(components): initial mutations over time component --- components/package-lock.json | 9 +- components/package.json | 2 + components/src/constants.ts | 2 +- .../mutationComparison/queryMutationData.ts | 16 +- .../mutation-over-time-grid.tsx | 92 ++++++++ .../mutation-over-time-table.tsx | 41 ++++ .../mutation-over-time.stories.tsx | 67 ++++++ .../mutationOverTime/mutation-over-time.tsx | 211 +++++++++++++++++ .../preact/shared/table/formatProportion.ts | 4 +- components/src/query/queryMutationOverTime.ts | 218 ++++++++++++++++++ .../src/query/queryPrevalenceOverTime.ts | 12 +- .../src/query/queryRelativeGrowthAdvantage.ts | 8 +- components/src/utils/Map2d.ts | 75 ++++++ components/src/utils/map2d.spec.ts | 94 ++++++++ components/src/utils/mutations.ts | 6 +- components/src/utils/temporal.ts | 55 ++++- 16 files changed, 894 insertions(+), 18 deletions(-) create mode 100644 components/src/preact/mutationOverTime/mutation-over-time-grid.tsx create mode 100644 components/src/preact/mutationOverTime/mutation-over-time-table.tsx create mode 100644 components/src/preact/mutationOverTime/mutation-over-time.stories.tsx create mode 100644 components/src/preact/mutationOverTime/mutation-over-time.tsx create mode 100644 components/src/query/queryMutationOverTime.ts create mode 100644 components/src/utils/Map2d.ts create mode 100644 components/src/utils/map2d.spec.ts diff --git a/components/package-lock.json b/components/package-lock.json index f7f68873..7f8452ba 100644 --- a/components/package-lock.json +++ b/components/package-lock.json @@ -21,6 +21,7 @@ "flatpickr": "^4.6.13", "gridjs": "^6.2.0", "lit": "^3.1.3", + "object-hash": "^3.0.0", "preact": "^10.20.1", "zod": "^3.23.0" }, @@ -40,6 +41,7 @@ "@storybook/web-components": "^8.0.9", "@storybook/web-components-vite": "^8.0.9", "@types/node": "^20.12.7", + "@types/object-hash": "^3.0.6", "@typescript-eslint/eslint-plugin": "^7.14.1", "@typescript-eslint/parser": "^7.14.1", "autoprefixer": "^10.4.19", @@ -7405,6 +7407,12 @@ "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "dev": true }, + "node_modules/@types/object-hash": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/object-hash/-/object-hash-3.0.6.tgz", + "integrity": "sha512-fOBV8C1FIu2ELinoILQ+ApxcUKz4ngq+IWUYrxSGjXzzjUALijilampwkMgEtJ+h2njAW3pi853QpzNVCHB73w==", + "dev": true + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -19229,7 +19237,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, "engines": { "node": ">= 6" } diff --git a/components/package.json b/components/package.json index 2e96ef37..c0ccae6b 100644 --- a/components/package.json +++ b/components/package.json @@ -68,6 +68,7 @@ "flatpickr": "^4.6.13", "gridjs": "^6.2.0", "lit": "^3.1.3", + "object-hash": "^3.0.0", "preact": "^10.20.1", "zod": "^3.23.0" }, @@ -87,6 +88,7 @@ "@storybook/web-components": "^8.0.9", "@storybook/web-components-vite": "^8.0.9", "@types/node": "^20.12.7", + "@types/object-hash": "^3.0.6", "@typescript-eslint/eslint-plugin": "^7.14.1", "@typescript-eslint/parser": "^7.14.1", "autoprefixer": "^10.4.19", diff --git a/components/src/constants.ts b/components/src/constants.ts index b00fe78d..a44a7ead 100644 --- a/components/src/constants.ts +++ b/components/src/constants.ts @@ -1,4 +1,4 @@ -export const LAPIS_URL = 'https://lapis.cov-spectrum.org/open/v2/'; +export const LAPIS_URL = 'https://lapis.cov-spectrum.org/open/v2'; export const AGGREGATED_ENDPOINT = `${LAPIS_URL}/sample/aggregated`; export const NUCLEOTIDE_MUTATIONS_ENDPOINT = `${LAPIS_URL}/sample/nucleotideMutations`; diff --git a/components/src/preact/mutationComparison/queryMutationData.ts b/components/src/preact/mutationComparison/queryMutationData.ts index 165bdaf9..e02155cc 100644 --- a/components/src/preact/mutationComparison/queryMutationData.ts +++ b/components/src/preact/mutationComparison/queryMutationData.ts @@ -28,6 +28,17 @@ export function filterMutationData( data: MutationData[], displayedSegments: DisplayedSegment[], displayedMutationTypes: DisplayedMutationType[], +) { + return data.map((mutationEntry) => ({ + displayName: mutationEntry.displayName, + data: filterBySegmentAndMutationType(mutationEntry.data, displayedSegments, displayedMutationTypes), + })); +} + +export function filterBySegmentAndMutationType( + data: SubstitutionOrDeletionEntry[], + displayedSegments: DisplayedSegment[], + displayedMutationTypes: DisplayedMutationType[], ) { const byDisplayedSegments = (mutationEntry: SubstitutionOrDeletionEntry) => { if (mutationEntry.mutation.segment === undefined) { @@ -45,8 +56,5 @@ export function filterMutationData( ); }; - return data.map((mutationEntry) => ({ - displayName: mutationEntry.displayName, - data: mutationEntry.data.filter(byDisplayedSegments).filter(byDisplayedMutationTypes), - })); + return data.filter(byDisplayedSegments).filter(byDisplayedMutationTypes); } diff --git a/components/src/preact/mutationOverTime/mutation-over-time-grid.tsx b/components/src/preact/mutationOverTime/mutation-over-time-grid.tsx new file mode 100644 index 00000000..8cc652b1 --- /dev/null +++ b/components/src/preact/mutationOverTime/mutation-over-time-grid.tsx @@ -0,0 +1,92 @@ +import { Fragment, type FunctionComponent } from 'preact'; + +import { + type MutationOverTimeDataGroupedByMutation, + type MutationOverTimeMutationValue, +} from '../../query/queryMutationOverTime'; +import { type Deletion, type Substitution } from '../../utils/mutations'; +import { compareTemporal, type Temporal } from '../../utils/temporal'; +import { singleGraphColorRGBByName } from '../shared/charts/colors'; +import { formatProportion } from '../shared/table/formatProportion'; + +export interface MutationOverTimeGridProps { + data: MutationOverTimeDataGroupedByMutation; +} + +const MutationOverTimeGrid: FunctionComponent = ({ data }) => { + const mutations = data.getFirstAxisKeys(); + const dates = data.getSecondAxisKeys().sort((a, b) => compareTemporal(a, b)); + + return ( +
+ {mutations.map((mutation, i) => { + return ( + +
+ +
+ {dates.map((date, j) => { + const value = data.get(mutation, date) ?? 0; + return ( +
+ +
+ ); + })} +
+ ); + })} +
+ ); +}; + +const ProportionCell: FunctionComponent<{ + value: MutationOverTimeMutationValue; + date: Temporal; + mutation: Substitution | Deletion; +}> = ({ value }) => { + // TODO(#353): Add tooltip with date, mutation and proportion + return ( + <> +
+
+ {formatProportion(value, 0)} +
+
+ + ); +}; + +const backgroundColor = (proportion: number) => { + // TODO(#353): Make minAlpha and maxAlpha configurable + const minAlpha = 0.0; + const maxAlpha = 1; + + const alpha = minAlpha + (maxAlpha - minAlpha) * proportion; + return singleGraphColorRGBByName('indigo', alpha); +}; + +const textColor = (proportion: number) => { + return proportion > 0.5 ? 'white' : 'black'; +}; + +const MutationCell: FunctionComponent<{ mutation: Substitution | Deletion }> = ({ mutation }) => { + return
{mutation.toString()}
; +}; + +export default MutationOverTimeGrid; diff --git a/components/src/preact/mutationOverTime/mutation-over-time-table.tsx b/components/src/preact/mutationOverTime/mutation-over-time-table.tsx new file mode 100644 index 00000000..f3964c38 --- /dev/null +++ b/components/src/preact/mutationOverTime/mutation-over-time-table.tsx @@ -0,0 +1,41 @@ +import { type FunctionComponent } from 'preact'; + +import { type MutationOverTimeDataGroupedByMutation } from '../../query/queryMutationOverTime'; +import type { Deletion, Substitution } from '../../utils/mutations'; +import { Table } from '../components/table'; +import { sortSubstitutionsAndDeletions } from '../shared/sort/sortSubstitutionsAndDeletions'; +import { formatProportion } from '../shared/table/formatProportion'; + +export interface MutationOverTimeTableProps { + data: MutationOverTimeDataGroupedByMutation; + pageSize: boolean | number; +} + +const MutationOverTimeTable: FunctionComponent = ({ data, pageSize }) => { + const getHeaders = () => { + return [ + { + name: 'Mutation', + sort: { + compare: sortSubstitutionsAndDeletions, + }, + formatter: (cell: Substitution | Deletion) => cell.toString(), + }, + ...data.getSecondAxisKeys().map((date) => { + return { + name: date.toString(), + sort: true, + formatter: (cell: number) => formatProportion(cell), + }; + }), + ]; + }; + + const tableData = data.getFirstAxisKeys().map((mutation) => { + return [mutation, ...data.getRow(mutation, 0)]; + }); + + return ; +}; + +export default MutationOverTimeTable; diff --git a/components/src/preact/mutationOverTime/mutation-over-time.stories.tsx b/components/src/preact/mutationOverTime/mutation-over-time.stories.tsx new file mode 100644 index 00000000..ef9c1221 --- /dev/null +++ b/components/src/preact/mutationOverTime/mutation-over-time.stories.tsx @@ -0,0 +1,67 @@ +import { type Meta, type StoryObj } from '@storybook/preact'; + +import { MutationOverTime, type MutationOverTimeProps } from './mutation-over-time'; +import { LAPIS_URL } from '../../constants'; +import referenceGenome from '../../lapisApi/__mockData__/referenceGenome.json'; +import { LapisUrlContext } from '../LapisUrlContext'; +import { ReferenceGenomeContext } from '../ReferenceGenomeContext'; + +const meta: Meta = { + title: 'Visualization/Mutation over time', + component: MutationOverTime, + argTypes: { + lapisFilter: { control: 'object' }, + sequenceType: { + options: ['nucleotide', 'amino acid'], + control: { type: 'radio' }, + }, + views: { + options: ['table', 'grid', 'insertions'], + control: { type: 'check' }, + }, + width: { control: 'text' }, + height: { control: 'text' }, + headline: { control: 'text' }, + pageSize: { control: 'object' }, + }, +}; + +export default meta; + +const Template = { + render: (args: MutationOverTimeProps) => ( + + + + + + ), +}; + +export const Default: StoryObj = { + ...Template, + args: { + lapisFilter: { pangoLineage: 'JN.1*', dateFrom: '2024-01-15', dateTo: '2024-07-10' }, + sequenceType: 'nucleotide', + views: ['grid'], + width: '100%', + height: '700px', + headline: 'Mutation over time', + pageSize: true, + granularity: 'year', + }, + parameters: { + fetchMock: { + mocks: [], + }, + }, +}; diff --git a/components/src/preact/mutationOverTime/mutation-over-time.tsx b/components/src/preact/mutationOverTime/mutation-over-time.tsx new file mode 100644 index 00000000..595df4da --- /dev/null +++ b/components/src/preact/mutationOverTime/mutation-over-time.tsx @@ -0,0 +1,211 @@ +import { type FunctionComponent } from 'preact'; +import { type Dispatch, type StateUpdater, useContext, useMemo, useState } from 'preact/hooks'; + +import MutationOverTimeGrid from './mutation-over-time-grid'; +import MutationOverTimeTable from './mutation-over-time-table'; +import { + filterMutationOverTimeData, + type MutationOverTimeDataGroupedByMutation, + queryMutationOverTimeData, +} from '../../query/queryMutationOverTime'; +import { type LapisFilter, type SequenceType, type TemporalGranularity } from '../../types'; +import { LapisUrlContext } from '../LapisUrlContext'; +import { type DisplayedSegment, SegmentSelector, useDisplayedSegments } from '../components/SegmentSelector'; +import { CsvDownloadButton } from '../components/csv-download-button'; +import { ErrorBoundary } from '../components/error-boundary'; +import { ErrorDisplay } from '../components/error-display'; +import Headline from '../components/headline'; +import Info from '../components/info'; +import { LoadingDisplay } from '../components/loading-display'; +import { type DisplayedMutationType, MutationTypeSelector } from '../components/mutation-type-selector'; +import { NoDataDisplay } from '../components/no-data-display'; +import type { ProportionInterval } from '../components/proportion-selector'; +import { ProportionSelectorDropdown } from '../components/proportion-selector-dropdown'; +import { ResizeContainer } from '../components/resize-container'; +import Tabs from '../components/tabs'; +import { useQuery } from '../useQuery'; + +export type View = 'table' | 'grid'; + +export interface MutationOverTimeInnerProps { + lapisFilter: LapisFilter; + sequenceType: SequenceType; + views: View[]; + pageSize: boolean | number; + granularity: TemporalGranularity; +} + +export interface MutationOverTimeProps extends MutationOverTimeInnerProps { + width: string; + height: string; + headline?: string; +} + +export const MutationOverTime: FunctionComponent = ({ + lapisFilter, + sequenceType, + views, + width, + height, + headline = 'Mutation over time', + pageSize, + granularity, +}) => { + const size = { height, width }; + + return ( + + + + + + + + ); +}; + +export const MutationOverTimeInner: FunctionComponent = ({ + lapisFilter, + sequenceType, + views, + pageSize, + granularity, +}) => { + const lapis = useContext(LapisUrlContext); + const { data, error, isLoading } = useQuery(async () => { + return queryMutationOverTimeData(lapisFilter, sequenceType, lapis, 'date', granularity); + }, [lapisFilter, sequenceType, lapis]); + + if (isLoading) { + return ; + } + + if (error !== null) { + return ; + } + + if (data === null) { + return ; + } + + return ( + + ); +}; + +type MutationOverTimeTabsProps = { + mutationOverTimeData: MutationOverTimeDataGroupedByMutation; + sequenceType: SequenceType; + views: View[]; + pageSize: boolean | number; +}; + +const MutationOverTimeTabs: FunctionComponent = ({ + mutationOverTimeData, + sequenceType, + views, + pageSize, +}) => { + const [proportionInterval, setProportionInterval] = useState({ min: 0.05, max: 0.9 }); + + const [displayedSegments, setDisplayedSegments] = useDisplayedSegments(sequenceType); + const [displayedMutationTypes, setDisplayedMutationTypes] = useState([ + { label: 'Substitutions', checked: true, type: 'substitution' }, + { label: 'Deletions', checked: true, type: 'deletion' }, + ]); + + const filteredData = useMemo( + () => + filterMutationOverTimeData( + mutationOverTimeData.copy(), + displayedSegments, + displayedMutationTypes, + proportionInterval, + ), + [mutationOverTimeData, displayedSegments, displayedMutationTypes, proportionInterval], + ); + + const getTab = (view: View) => { + switch (view) { + case 'table': + return { + title: 'Table', + content: , + }; + case 'grid': + return { + title: 'Grid', + content: , + }; + } + }; + + const tabs = views.map((view) => getTab(view)); + + const toolbar = () => ( + + ); + + return ; +}; + +type ToolbarProps = { + displayedSegments: DisplayedSegment[]; + setDisplayedSegments: (segments: DisplayedSegment[]) => void; + displayedMutationTypes: DisplayedMutationType[]; + setDisplayedMutationTypes: (types: DisplayedMutationType[]) => void; + proportionInterval: ProportionInterval; + setProportionInterval: Dispatch>; +}; + +const Toolbar: FunctionComponent = ({ + displayedSegments, + setDisplayedSegments, + displayedMutationTypes, + setDisplayedMutationTypes, + proportionInterval, + setProportionInterval, +}) => { + return ( + <> + + + <> + setProportionInterval((prev) => ({ ...prev, min }))} + setMaxProportion={(max) => setProportionInterval((prev) => ({ ...prev, max }))} + /> + { + return [{ value: 1 }, { value: 2 }]; + }} + filename='mutation-over-time.csv' + /> + + Info for mutation over time + + ); +}; diff --git a/components/src/preact/shared/table/formatProportion.ts b/components/src/preact/shared/table/formatProportion.ts index 71c48b64..96ecb00e 100644 --- a/components/src/preact/shared/table/formatProportion.ts +++ b/components/src/preact/shared/table/formatProportion.ts @@ -1,3 +1,3 @@ -export const formatProportion = (proportion: number) => { - return `${(proportion * 100).toFixed(2)}%`; +export const formatProportion = (proportion: number, digits: number = 2) => { + return `${(proportion * 100).toFixed(digits)}%`; }; diff --git a/components/src/query/queryMutationOverTime.ts b/components/src/query/queryMutationOverTime.ts new file mode 100644 index 00000000..40676f87 --- /dev/null +++ b/components/src/query/queryMutationOverTime.ts @@ -0,0 +1,218 @@ +import { dateRangeCompare, mapDateToGranularityRange } from './queryPrevalenceOverTime'; +import { FetchAggregatedOperator } from '../operator/FetchAggregatedOperator'; +import { FetchSubstitutionsOrDeletionsOperator } from '../operator/FetchSubstitutionsOrDeletionsOperator'; +import { GroupByAndSumOperator } from '../operator/GroupByAndSumOperator'; +import { MapOperator } from '../operator/MapOperator'; +import { RenameFieldOperator } from '../operator/RenameFieldOperator'; +import { SortOperator } from '../operator/SortOperator'; +import type { DisplayedSegment } from '../preact/components/SegmentSelector'; +import type { DisplayedMutationType } from '../preact/components/mutation-type-selector'; +import { + type LapisFilter, + type SequenceType, + type SubstitutionOrDeletionEntry, + type TemporalGranularity, +} from '../types'; +import { Map2d } from '../utils/Map2d'; +import { type Deletion, type Substitution } from '../utils/mutations'; +import { generateAllInRange, getMinMaxTemporal, parseDate, type Temporal } from '../utils/temporal'; + +export type MutationOverTimeData = { + date: Temporal; + mutations: SubstitutionOrDeletionEntry[]; +}; + +export type MutationOverTimeMutationValue = number; +export type MutationOverTimeDataGroupedByMutation = Map2d< + Substitution | Deletion, + Temporal, + MutationOverTimeMutationValue +>; + +export async function queryMutationOverTimeData( + lapisFilter: LapisFilter, + sequenceType: 'nucleotide' | 'amino acid', + lapis: string, + lapisDateField: string, + granularity: TemporalGranularity, + signal?: AbortSignal, +) { + const allDates = await getDatesInDataset(lapisFilter, lapis, granularity, lapisDateField, signal); + + const subQueries = allDates.map(async (date) => { + const dateFrom = date.firstDay.toString(); + const dateTo = date.lastDay.toString(); + + const filter = { + ...lapisFilter, + [`${lapisDateField}From`]: dateFrom, + [`${lapisDateField}To`]: dateTo, + }; + + const data = await fetchAndPrepare(filter, sequenceType).evaluate(lapis, signal); + return { + date, + mutations: data.content, + }; + }); + + const data = await Promise.all(subQueries); + + return groupByMutation(data); +} + +async function getDatesInDataset( + lapisFilter: LapisFilter, + lapis: string, + granularity: 'day' | 'week' | 'month' | 'year', + lapisDateField: string, + signal: AbortSignal | undefined, +) { + const { content: availableDates } = await queryAvailableDates( + lapisFilter, + lapis, + granularity, + lapisDateField, + signal, + ); + + const { dateFrom, dateTo } = getDateRangeFromFilter(lapisFilter, lapisDateField, granularity); + const { min, max } = getMinMaxTemporal([...availableDates, dateFrom, dateTo]); + return generateAllInRange(min, max); +} + +function getDateRangeFromFilter(lapisFilter: LapisFilter, lapisDateField: string, granularity: TemporalGranularity) { + const valueFromFilter = lapisFilter[lapisDateField] as string | null; + + if (valueFromFilter) { + return { + dateFrom: parseDate(valueFromFilter, granularity), + dateTo: parseDate(valueFromFilter, granularity), + }; + } + + const minFromFilter = lapisFilter[`${lapisDateField}From`] as string | null; + const maxFromFilter = lapisFilter[`${lapisDateField}To`] as string | null; + + return { + dateFrom: minFromFilter ? parseDate(minFromFilter, granularity) : null, + dateTo: maxFromFilter ? parseDate(maxFromFilter, granularity) : null, + }; +} + +function queryAvailableDates( + lapisFilter: LapisFilter, + lapis: string, + granularity: TemporalGranularity, + lapisDateField: string, + signal?: AbortSignal, +) { + return fetchAndPrepareDates(lapisFilter, granularity, lapisDateField).evaluate(lapis, signal); +} + +function fetchAndPrepareDates( + filter: LapisFilter, + granularity: TemporalGranularity, + lapisDateField: LapisDateField, +) { + const fetchData = new FetchAggregatedOperator<{ [key in LapisDateField]: string | null }>(filter, [lapisDateField]); + const dataWithFixedDateKey = new RenameFieldOperator(fetchData, lapisDateField, 'date'); + const mapData = new MapOperator(dataWithFixedDateKey, (data) => mapDateToGranularityRange(data, granularity)); + const groupByData = new GroupByAndSumOperator(mapData, 'dateRange', 'count'); + const sortData = new SortOperator(groupByData, dateRangeCompare); + return new MapOperator(sortData, (data) => data.dateRange); +} + +function fetchAndPrepare(filter: LapisFilter, sequenceType: SequenceType) { + return new FetchSubstitutionsOrDeletionsOperator(filter, sequenceType); +} + +export function filterMutationOverTimeData( + data: Map2d, + displayedSegments: DisplayedSegment[], + displayedMutationTypes: DisplayedMutationType[], + proportionInterval: { min: number; max: number }, +) { + filterDisplayedSegments(displayedSegments, data); + filterMutationTypes(displayedMutationTypes, data); + filterProportion(data, proportionInterval); + + return data; +} + +function filterDisplayedSegments( + displayedSegments: DisplayedSegment[], + data: Map2d, +) { + displayedSegments.forEach((segment) => { + if (!segment.checked) { + data.getFirstAxisKeys().forEach((mutation) => { + if (mutation.segment === segment.segment) { + data.deleteRow(mutation); + } + }); + } + }); +} + +function filterMutationTypes( + displayedMutationTypes: DisplayedMutationType[], + data: Map2d, +) { + displayedMutationTypes.forEach((mutationType) => { + if (!mutationType.checked) { + data.getFirstAxisKeys().forEach((mutation) => { + if (mutationType.type === mutation.type) { + data.deleteRow(mutation); + } + }); + } + }); +} + +function filterProportion( + data: Map2d, + proportionInterval: { + min: number; + max: number; + }, +) { + data.getFirstAxisKeys().forEach((mutation) => { + const row = data.getRow(mutation, 0); + if ( + !row.some((value) => { + return value >= proportionInterval.min && value <= proportionInterval.max; + }) + ) { + data.deleteRow(mutation); + } + }); +} + + + +export function groupByMutation(data: MutationOverTimeData[]) { + const dataArray = new Map2d( + (mutation) => mutation.code, + (date) => date.toString(), + ); + + data.forEach((mutationData) => { + mutationData.mutations.forEach((mutationEntry) => { + dataArray.set(mutationEntry.mutation, mutationData.date, mutationEntry.proportion); + }); + }); + + addZeroValuesForDatesWithNoMutationData(dataArray, data); + + return dataArray; +} + +function addZeroValuesForDatesWithNoMutationData(dataArray: Map2d, data: MutationOverTimeData[]) { + const someMutation = dataArray.getFirstAxisKeys()[0]; + data.forEach((mutationData) => { + if (mutationData.mutations.length === 0) { + dataArray.set(someMutation, mutationData.date, 0); + } + }); +} \ No newline at end of file diff --git a/components/src/query/queryPrevalenceOverTime.ts b/components/src/query/queryPrevalenceOverTime.ts index fadcad6b..f75ba326 100644 --- a/components/src/query/queryPrevalenceOverTime.ts +++ b/components/src/query/queryPrevalenceOverTime.ts @@ -75,7 +75,13 @@ function fetchAndPrepare( const fillData = new FillMissingOperator( groupByData, 'dateRange', - getMinMaxTemporal, + (values: Iterable) => { + const { min, max } = getMinMaxTemporal(values); + if (min === null && max === null) { + return null; + } + return [min, max]; + }, generateAllInRange, (key) => ({ dateRange: key, count: 0 }), ); @@ -84,7 +90,7 @@ function fetchAndPrepare( return smoothingWindow >= 1 ? new SlidingOperator(sortData, smoothingWindow, averageSmoothing) : sortData; } -function mapDateToGranularityRange(d: { date: string | null; count: number }, granularity: TemporalGranularity) { +export function mapDateToGranularityRange(d: { date: string | null; count: number }, granularity: TemporalGranularity) { let dateRange: Temporal | null = null; if (d.date !== null) { const date = TemporalCache.getInstance().getYearMonthDay(d.date); @@ -109,7 +115,7 @@ function mapDateToGranularityRange(d: { date: string | null; count: number }, gr }; } -function dateRangeCompare(a: { dateRange: Temporal | null }, b: { dateRange: Temporal | null }) { +export function dateRangeCompare(a: { dateRange: Temporal | null }, b: { dateRange: Temporal | null }) { if (a.dateRange === null) { return 1; } diff --git a/components/src/query/queryRelativeGrowthAdvantage.ts b/components/src/query/queryRelativeGrowthAdvantage.ts index 158b83e2..4708ffcb 100644 --- a/components/src/query/queryRelativeGrowthAdvantage.ts +++ b/components/src/query/queryRelativeGrowthAdvantage.ts @@ -28,11 +28,13 @@ export async function queryRelativeGrowthAdvantage d.date)); - if (!minMaxDate) { + const {min, max} = getMinMaxTemporal(denominatorData.content.map((d) => d.date)); + if (!min && !max) { return null; } - const [minDate, maxDate] = minMaxDate as [YearMonthDay, YearMonthDay]; + const minDate = min as YearMonthDay; + const maxDate = max as YearMonthDay; + const numeratorCounts = new Map(); numeratorData.content.forEach((d) => { if (d.date) { diff --git a/components/src/utils/Map2d.ts b/components/src/utils/Map2d.ts new file mode 100644 index 00000000..39b20fe7 --- /dev/null +++ b/components/src/utils/Map2d.ts @@ -0,0 +1,75 @@ +import hash from 'object-hash'; + +export class Map2d { + readonly data: Map> = new Map>(); + readonly keysFirstAxis = new Map(); + readonly keysSecondAxis = new Map(); + + constructor( + readonly serializeFirstAxis: (key: Key1) => string = (key) => (typeof key === 'string' ? key : hash(key)), + readonly serializeSecondAxis: (key: Key2) => string = (key) => (typeof key === 'string' ? key : hash(key)), + ) {} + + get(keyFirstAxis: Key1, keySecondAxis: Key2) { + const serializedKeyFirstAxis = this.serializeFirstAxis(keyFirstAxis); + const serializedKeySecondAxis = this.serializeSecondAxis(keySecondAxis); + return this.data.get(serializedKeyFirstAxis)?.get(serializedKeySecondAxis); + } + + getRow(key: Key1, fillEmptyWith: Value) { + const serializedKeyFirstAxis = this.serializeFirstAxis(key); + const row = this.data.get(serializedKeyFirstAxis); + if (row === undefined) { + return []; + } + return Array.from(this.keysSecondAxis.keys()).map((key) => row.get(key) ?? fillEmptyWith); + } + + set(keyFirstAxis: Key1, keySecondAxis: Key2, value: Value) { + const serializedKeyFirstAxis = this.serializeFirstAxis(keyFirstAxis); + const serializedKeySecondAxis = this.serializeSecondAxis(keySecondAxis); + + if (!this.data.has(serializedKeyFirstAxis)) { + this.data.set(serializedKeyFirstAxis, new Map()); + } + + this.data.get(serializedKeyFirstAxis)!.set(serializedKeySecondAxis, value); + + this.keysFirstAxis.set(serializedKeyFirstAxis, keyFirstAxis); + this.keysSecondAxis.set(serializedKeySecondAxis, keySecondAxis); + } + + deleteRow(key: Key1) { + const serializedKeyFirstAxis = this.serializeFirstAxis(key); + this.data.delete(serializedKeyFirstAxis); + this.keysFirstAxis.delete(serializedKeyFirstAxis); + } + + getFirstAxisKeys() { + return Array.from(this.keysFirstAxis.values()); + } + + getSecondAxisKeys() { + return Array.from(this.keysSecondAxis.values()); + } + + getAsArray(fillEmptyWith: Value) { + return this.getFirstAxisKeys().map((firstAxisKey) => { + return this.getSecondAxisKeys().map((secondAxisKey) => { + return this.get(firstAxisKey, secondAxisKey) ?? fillEmptyWith; + }); + }); + } + + copy() { + const copy = new Map2d(this.serializeFirstAxis, this.serializeSecondAxis); + this.data.forEach((value, key) => { + const keyFirstAxis = this.keysFirstAxis.get(key); + value.forEach((value, key) => { + const keySecondAxis = this.keysSecondAxis.get(key); + copy.set(keyFirstAxis!, keySecondAxis!, value); + }); + }); + return copy; + } +} diff --git a/components/src/utils/map2d.spec.ts b/components/src/utils/map2d.spec.ts new file mode 100644 index 00000000..bcb9679c --- /dev/null +++ b/components/src/utils/map2d.spec.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; + +import { Map2d } from './Map2d'; + +describe('Map2d', () => { + it('should add a value and return it', () => { + const map2d = new Map2d(); + map2d.set('a', 'b', 2); + expect(map2d.get('a', 'b')).toBe(2); + }); + + it('should update a value', () => { + const map2d = new Map2d(); + map2d.set('a', 'b', 2); + map2d.set('a', 'b', 3); + expect(map2d.get('a', 'b')).toBe(3); + }); + + it('should return the data as an array', () => { + const map2d = new Map2d(); + map2d.set('a', 'b', 1); + map2d.set('a', 'd', 2); + map2d.set('c', 'b', 3); + map2d.set('c', 'd', 4); + + expect(map2d.getAsArray(0)).toEqual([ + [1, 2], + [3, 4], + ]); + }); + + it('should fill empty values with the given value', () => { + const map2d = new Map2d(); + map2d.set('a', 'b', 2); + map2d.set('c', 'd', 4); + expect(map2d.getAsArray(0)).toEqual([ + [2, 0], + [0, 4], + ]); + }); + + it('should return the keys from the first axis', () => { + const map2d = new Map2d(); + map2d.set('a', 'b', 2); + map2d.set('c', 'd', 4); + + expect(map2d.getFirstAxisKeys()).toEqual(['a', 'c']); + }); + + it('should return the keys from the second axis', () => { + const map2d = new Map2d(); + map2d.set('a', 'b', 2); + map2d.set('c', 'd', 4); + + expect(map2d.getSecondAxisKeys()).toEqual(['b', 'd']); + }); + + it('should work with objects as keys', () => { + const map2d = new Map2d<{ a: string }, { b: string }, number>(); + map2d.set({ a: 'a' }, { b: 'b' }, 2); + map2d.set({ a: 'second' }, { b: 'second' }, 3); + + expect(map2d.get({ a: 'a' }, { b: 'b' })).toBe(2); + expect(map2d.get({ a: 'second' }, { b: 'second' })).toBe(3); + }); + + it('should update a value with objects as keys', () => { + const map2d = new Map2d<{ a: string }, { b: string }, number>(); + map2d.set({ a: 'a' }, { b: 'b' }, 2); + map2d.set({ a: 'a' }, { b: 'b' }, 3); + expect(map2d.get({ a: 'a' }, { b: 'b' })).toBe(3); + }); + + it('should create a deep copy of the map', () => { + const map2d = new Map2d(); + map2d.set('a', 'b', 2); + expect(map2d.get('a', 'b')).toBe(2); + + const copy = map2d.copy(); + expect(copy.get('a', 'b')).toBe(2); + + map2d.deleteRow('a'); + expect(map2d.get('a', 'b')).toBe(undefined); + }); + + it('should return a row by key', () => { + const map2d = new Map2d(); + map2d.set('a', 'b', 2); + map2d.set('c', 'd', 4); + + expect(map2d.getRow('a', 0)).toEqual([2, 0]); + expect(map2d.getRow('c', 0)).toEqual([0, 4]); + }); +}); diff --git a/components/src/utils/mutations.ts b/components/src/utils/mutations.ts index 6fd02870..a2a8c440 100644 --- a/components/src/utils/mutations.ts +++ b/components/src/utils/mutations.ts @@ -1,9 +1,10 @@ -import { type SequenceType } from '../types'; +import { type MutationType, type SequenceType } from '../types'; export interface Mutation { readonly segment: string | undefined; readonly position: number; readonly code: string; + readonly type: MutationType; equals(other: Mutation): boolean; @@ -15,6 +16,7 @@ export const substitutionRegex = export class Substitution implements Mutation { readonly code; + readonly type = 'substitution'; constructor( readonly segment: string | undefined, @@ -62,6 +64,7 @@ export const deletionRegex = /^((?[A-Za-z0-9_-]+)(?=:):)?(?): [Temporal, Temporal] | null { +export function getMinMaxTemporal(values: Iterable) { let min = null; let max = null; for (const value of values) { @@ -326,9 +360,9 @@ export function getMinMaxTemporal(values: Iterable): [Temporal, } } if (min === null || max === null) { - return null; + return { min: null, max: null }; } - return [min, max]; + return { min, max }; } export function addUnit(temporal: Temporal, amount: number): Temporal { @@ -346,3 +380,18 @@ export function addUnit(temporal: Temporal, amount: number): Temporal { } throw new Error(`Invalid argument: ${temporal}`); } + +export function parseDate(date: string, granularity: TemporalGranularity) { + const cache = TemporalCache.getInstance(); + const day = cache.getYearMonthDay(date); + switch (granularity) { + case 'day': + return day; + case 'week': + return day.week; + case 'month': + return day.month; + case 'year': + return day.year; + } +}