@genspectrum/dashboard-components 1.15.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/custom-elements.json +5 -5
  2. package/dist/components.d.ts +66 -67
  3. package/dist/components.js +457 -256
  4. package/dist/components.js.map +1 -1
  5. package/dist/util.d.ts +69 -66
  6. package/package.json +2 -9
  7. package/src/preact/MutationAnnotationsContext.tsx +1 -1
  8. package/src/preact/components/csv-download-button.tsx +22 -14
  9. package/src/preact/components/features-over-time-grid.tsx +189 -43
  10. package/src/preact/components/mutations-over-time-mutations-filter.stories.tsx +1 -1
  11. package/src/preact/components/mutations-over-time-mutations-filter.tsx +1 -1
  12. package/src/preact/mutationsOverTime/__mockData__/aminoAcidMutationsByDay/aminoAcidMutationsOverTimePage1.json +52 -0
  13. package/src/preact/mutationsOverTime/__mockData__/byWeek/mutationsOverTimePage1.json +76 -0
  14. package/src/preact/mutationsOverTime/__mockData__/defaultMockData/mockDefaultMutationsOverTimeWithFilter.json +43 -0
  15. package/src/preact/mutationsOverTime/__mockData__/defaultMockData/mutationsOverTimePage1.json +126 -0
  16. package/src/preact/mutationsOverTime/__mockData__/defaultMockData/mutationsOverTimePage2.json +116 -0
  17. package/src/preact/mutationsOverTime/__mockData__/defaultMockData/mutationsOverTimePageSize20.json +216 -0
  18. package/src/preact/mutationsOverTime/getFilteredMutationCodes.spec.ts +236 -0
  19. package/src/preact/mutationsOverTime/{getFilteredMutationsOverTimeData.ts → getFilteredMutationCodes.ts} +32 -45
  20. package/src/preact/mutationsOverTime/mutations-over-time.stories.tsx +128 -23
  21. package/src/preact/mutationsOverTime/mutations-over-time.tsx +139 -74
  22. package/src/preact/mutationsOverTime/useMutationsOverTimePageData.ts +111 -0
  23. package/src/preact/queriesOverTime/queries-over-time-row-label-tooltip.stories.tsx +8 -10
  24. package/src/preact/queriesOverTime/queries-over-time-row-label-tooltip.tsx +7 -17
  25. package/src/preact/queriesOverTime/queries-over-time.tsx +1 -1
  26. package/src/preact/shared/tanstackTable/pagination-context.tsx +5 -2
  27. package/src/preact/shared/tanstackTable/pagination.tsx +11 -9
  28. package/src/preact/shared/tanstackTable/tanstackTable.tsx +7 -4
  29. package/src/preact/wastewater/mutationsOverTime/wastewater-mutations-over-time.tsx +1 -1
  30. package/src/query/queryMutationsOverTime.spec.ts +187 -662
  31. package/src/query/queryMutationsOverTime.ts +46 -33
  32. package/src/utilEntrypoint.ts +1 -1
  33. package/src/utils/useControlledState.ts +15 -0
  34. package/src/web-components/gs-app.ts +2 -3
  35. package/src/web-components/input/gs-date-range-filter.tsx +2 -3
  36. package/src/web-components/input/gs-lineage-filter.tsx +2 -3
  37. package/src/web-components/input/gs-location-filter.tsx +2 -3
  38. package/src/web-components/input/gs-mutation-filter.tsx +3 -4
  39. package/src/web-components/input/gs-number-range-filter.tsx +2 -3
  40. package/src/web-components/input/gs-text-filter.tsx +2 -3
  41. package/src/web-components/mutation-annotations-context.ts +3 -1
  42. package/src/web-components/visualization/gs-aggregate.tsx +2 -3
  43. package/src/web-components/visualization/gs-genome-data-viewer.tsx +2 -3
  44. package/src/web-components/visualization/gs-mutation-comparison.tsx +2 -3
  45. package/src/web-components/visualization/gs-mutations-over-time.stories.ts +78 -22
  46. package/src/web-components/visualization/gs-mutations-over-time.tsx +2 -3
  47. package/src/web-components/visualization/gs-mutations.tsx +7 -8
  48. package/src/web-components/visualization/gs-number-sequences-over-time.tsx +2 -3
  49. package/src/web-components/visualization/gs-prevalence-over-time.tsx +2 -3
  50. package/src/web-components/visualization/gs-queries-over-time.tsx +2 -3
  51. package/src/web-components/visualization/gs-relative-growth-advantage.tsx +2 -3
  52. package/src/web-components/visualization/gs-sequences-by-location.tsx +2 -3
  53. package/src/web-components/visualization/gs-statistics.tsx +2 -3
  54. package/src/web-components/wastewaterVisualization/gs-wastewater-mutations-over-time.tsx +2 -3
  55. package/standalone-bundle/dashboard-components.js +6877 -6697
  56. package/standalone-bundle/dashboard-components.js.map +1 -1
  57. package/src/preact/mutationsOverTime/__mockData__/aminoAcidMutationsByDay/aminoAcidMutationsOverTime.json +0 -5496
  58. package/src/preact/mutationsOverTime/__mockData__/byWeek/mutationsOverTime.json +0 -7100
  59. package/src/preact/mutationsOverTime/__mockData__/defaultMockData/mutationsOverTime.json +0 -12646
  60. package/src/preact/mutationsOverTime/getFilteredMutationsOverTime.spec.ts +0 -417
@@ -0,0 +1,111 @@
1
+ import { useEffect, useMemo, useRef } from 'preact/hooks';
2
+
3
+ import { type MutationOverTimeDataMap } from './MutationOverTimeData';
4
+ import { type MutationsOverTimeMetadata, queryMutationsOverTimePage } from '../../query/queryMutationsOverTime';
5
+ import { type LapisFilter } from '../../types';
6
+ import { Map2dView } from '../../utils/map2d';
7
+ import { useQuery } from '../useQuery';
8
+
9
+ type MutationsOverTimePageQuery =
10
+ | { isLoading: true; data: null; pageMutationCodes: string[] }
11
+ | { isLoading: false; data: MutationOverTimeDataMap; pageMutationCodes: string[] };
12
+
13
+ /**
14
+ * Fetches the data for a single page of the mutations-over-time grid.
15
+ *
16
+ * Rather than loading all mutation data at once, this hook fetches only the rows (mutation codes)
17
+ * that are visible on the current page. The set of visible mutations is determined by
18
+ * `filteredMutationCodes` (all mutations that pass the current client-side filters), sliced to
19
+ * the current page according to `pageIndex` and `pageSize`.
20
+ *
21
+ * Results are cached in a `useRef`-backed Map keyed by the full query context (filter, date
22
+ * ranges, sequence type, and the specific mutation codes on the page), so revisiting a page does
23
+ * not trigger a new network request. The cache is cleared automatically whenever the underlying
24
+ * query inputs change (i.e. `lapisFilter`, `lapis`, `lapisDateField`, `sequenceType`, or
25
+ * `requestedDateRanges`), ensuring stale data is never served after the dataset changes.
26
+ *
27
+ * When `hideGaps` is true, date-range columns that contain no data are removed from the returned
28
+ * view before it is passed to the grid.
29
+ */
30
+ export function useMutationsOverTimePageData(
31
+ filteredMutationCodes: string[],
32
+ pageIndex: number,
33
+ pageSize: number,
34
+ lapisFilter: LapisFilter,
35
+ lapis: string,
36
+ lapisDateField: string,
37
+ sequenceType: 'nucleotide' | 'amino acid',
38
+ requestedDateRanges: MutationsOverTimeMetadata['requestedDateRanges'],
39
+ hideGaps: boolean,
40
+ ): MutationsOverTimePageQuery {
41
+ const pageMutationCodes = useMemo(
42
+ () => filteredMutationCodes.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize),
43
+ [filteredMutationCodes, pageIndex, pageSize],
44
+ );
45
+
46
+ const cache = useRef<Map<string, MutationOverTimeDataMap>>(new Map());
47
+ useEffect(() => {
48
+ cache.current = new Map(); // make sure the cache doesn't grow indefinitely
49
+ }, [lapisFilter, lapis, lapisDateField, sequenceType, requestedDateRanges]);
50
+
51
+ const {
52
+ data: pageData,
53
+ error,
54
+ isLoading,
55
+ } = useQuery(async () => {
56
+ const cacheKey = JSON.stringify({
57
+ lapisFilter,
58
+ lapis,
59
+ lapisDateField,
60
+ sequenceType,
61
+ requestedDateRanges,
62
+ pageMutationCodes,
63
+ });
64
+ const cachedData = cache.current.get(cacheKey);
65
+ if (cachedData !== undefined) {
66
+ return cachedData;
67
+ }
68
+
69
+ const newData = await queryMutationsOverTimePage(
70
+ lapisFilter,
71
+ lapis,
72
+ lapisDateField,
73
+ sequenceType,
74
+ requestedDateRanges,
75
+ pageMutationCodes,
76
+ );
77
+ cache.current.set(cacheKey, newData);
78
+ return newData;
79
+ }, [lapisFilter, lapis, lapisDateField, sequenceType, requestedDateRanges, pageMutationCodes]);
80
+
81
+ if (error) {
82
+ throw error;
83
+ }
84
+
85
+ return useMemo(() => {
86
+ if (isLoading) {
87
+ return { isLoading: true, data: null, pageMutationCodes };
88
+ }
89
+
90
+ return { isLoading: false, data: handleHideGaps(pageData, hideGaps), pageMutationCodes };
91
+ }, [pageData, hideGaps, isLoading, pageMutationCodes]);
92
+ }
93
+
94
+ /**
95
+ * Returns a new view on `data` where columns (date ranges) that do not contain any data are
96
+ * filtered out. When `hideGaps` is false the original data is returned unchanged.
97
+ */
98
+ export function handleHideGaps(data: MutationOverTimeDataMap, hideGaps: boolean) {
99
+ if (!hideGaps) {
100
+ return data;
101
+ }
102
+
103
+ const view = new Map2dView(data);
104
+ view.getSecondAxisKeys()
105
+ .filter((dateRange) => {
106
+ const vals = view.getColumn(dateRange);
107
+ return !vals.some((v) => (v?.type === 'value' || v?.type === 'valueWithCoverage') && v.totalCount > 0);
108
+ })
109
+ .forEach((dateRange) => view.deleteColumn(dateRange));
110
+ return view;
111
+ }
@@ -23,20 +23,20 @@ export const Default: StoryObj<QueriesOverTimeRowLabelTooltipProps> = {
23
23
  render: (args) => <QueriesOverTimeRowLabelTooltip {...args} />,
24
24
  args: {
25
25
  query: {
26
- displayLabel: 'S:F456L (single mutation)',
27
- description: 'This mutation is associated with increased transmissibility.',
28
- countQuery: 'S:456L',
26
+ displayLabel: 'Foo variant',
27
+ description: 'These mutations are associated with increased transmissibility.',
28
+ countQuery: 'S:451L & S:452L & S:453L & S:454L & S:455L & S:456L & S:457L & S:458L & S:459L & S:460L',
29
29
  coverageQuery: '!S:456N',
30
30
  },
31
31
  },
32
32
  play: async ({ canvasElement }) => {
33
33
  const canvas = within(canvasElement);
34
- await expect(canvas.getByText('S:F456L (single mutation)', { exact: true })).toBeVisible();
35
- await expect(canvas.getByText('This mutation is associated with increased transmissibility.')).toBeVisible();
34
+ await expect(canvas.getByText('Foo variant', { exact: true })).toBeVisible();
35
+ await expect(canvas.getByText('These mutations are associated with increased transmissibility.')).toBeVisible();
36
36
  await expect(canvas.getByText('Count query:')).toBeVisible();
37
- await expect(canvas.getByText('S:456L')).toBeVisible();
38
- await expect(canvas.getByText('Coverage query:')).toBeVisible();
39
- await expect(canvas.getByText('!S:456N')).toBeVisible();
37
+ await expect(
38
+ canvas.getByText('S:451L & S:452L & S:453L & S:454L & S:455L & S:456L & S:457L & S:458L & S:459L & S:460L'),
39
+ ).toBeVisible();
40
40
  },
41
41
  };
42
42
 
@@ -54,7 +54,5 @@ export const WithoutDescription: StoryObj<QueriesOverTimeRowLabelTooltipProps> =
54
54
  await expect(canvas.getByText('S:R346T', { exact: true })).toBeVisible();
55
55
  await expect(canvas.getByText('Count query:')).toBeVisible();
56
56
  await expect(canvas.getByText('S:346T')).toBeVisible();
57
- await expect(canvas.getByText('Coverage query:')).toBeVisible();
58
- await expect(canvas.getByText('!S:346N')).toBeVisible();
59
57
  },
60
58
  };
@@ -8,25 +8,15 @@ export type QueriesOverTimeRowLabelTooltipProps = {
8
8
 
9
9
  export const QueriesOverTimeRowLabelTooltip: FunctionComponent<QueriesOverTimeRowLabelTooltipProps> = ({ query }) => {
10
10
  return (
11
- <div className='flex flex-col gap-2'>
11
+ <div className='flex flex-col gap-2 max-w-xl'>
12
12
  <div className='font-bold'>{query.displayLabel}</div>
13
13
  {query.description && <div className='text-sm text-gray-700'>{query.description}</div>}
14
- <div className='flex flex-col gap-1'>
15
- <div className='text-sm'>
16
- <span className='text-gray-600'>Count query:</span>
17
- <div className='p-2 border border-gray-200 rounded bg-gray-50 overflow-x-auto'>
18
- <pre className='text-xs'>
19
- <code>{query.countQuery}</code>
20
- </pre>
21
- </div>
22
- </div>
23
- <div className='text-sm'>
24
- <span className='text-gray-600'>Coverage query:</span>
25
- <div className='p-2 border border-gray-200 rounded bg-gray-50 overflow-x-auto'>
26
- <pre className='text-xs'>
27
- <code>{query.coverageQuery}</code>
28
- </pre>
29
- </div>
14
+ <div className='text-sm'>
15
+ <span className='text-gray-600'>Count query:</span>
16
+ <div className='p-2 border border-gray-200 rounded bg-gray-50'>
17
+ <pre className='text-xs whitespace-pre-wrap'>
18
+ <code>{query.countQuery}</code>
19
+ </pre>
30
20
  </div>
31
21
  </div>
32
22
  </div>
@@ -177,7 +177,7 @@ const QueriesOverTimeTabs: FunctionComponent<QueriesOverTimeTabsProps> = ({
177
177
  position='right'
178
178
  portalTarget={tooltipPortalTarget}
179
179
  >
180
- <div className='text-center'>
180
+ <div className='text-center whitespace-nowrap mr-2'>
181
181
  <span>{value}</span>
182
182
  </div>
183
183
  </PortalTooltip>
@@ -1,7 +1,8 @@
1
1
  import { createContext, type FunctionComponent } from 'preact';
2
- import { type Dispatch, type StateUpdater, useContext, useState } from 'preact/hooks';
2
+ import { type Dispatch, type StateUpdater, useContext } from 'preact/hooks';
3
3
 
4
4
  import type { PageSizes } from './pagination';
5
+ import { useControlledState } from '../../../utils/useControlledState';
5
6
 
6
7
  type PageSizeContext = {
7
8
  pageSize: number;
@@ -24,7 +25,9 @@ export type PageSizeContextProviderProps = {
24
25
  };
25
26
 
26
27
  export const PageSizeContextProvider: FunctionComponent<PageSizeContextProviderProps> = ({ children, pageSizes }) => {
27
- const [pageSize, setPageSize] = useState(typeof pageSizes === 'number' ? pageSizes : (pageSizes.at(0) ?? 10));
28
+ const [pageSize, setPageSize] = useControlledState(
29
+ typeof pageSizes === 'number' ? pageSizes : (pageSizes.at(0) ?? 10),
30
+ );
28
31
 
29
32
  return <pageSizeContext.Provider value={{ pageSize, setPageSize }}>{children}</pageSizeContext.Provider>;
30
33
  };
@@ -13,16 +13,19 @@ export type PageSizes = z.infer<typeof pageSizesSchema>;
13
13
  export function Pagination({
14
14
  table,
15
15
  pageSizes,
16
+ totalRows,
16
17
  }: PaginationProps & {
17
18
  pageSizes: PageSizes;
19
+ /** Override the total row count (for server-driven pagination). */
20
+ totalRows: number;
18
21
  }) {
19
22
  return (
20
23
  <div className='@container'>
21
24
  <div className='flex items-center gap-x-6 gap-y-2 flex-wrap @xl:justify-end justify-center'>
22
25
  <PageSizeSelector table={table} pageSizes={pageSizes} />
23
- <PageIndicator table={table} />
26
+ <PageIndicator table={table} totalRows={totalRows} />
24
27
  <div className='@xl:block hidden'>
25
- <GotoPageSelector table={table} />
28
+ <GotoPageSelector table={table} totalRows={totalRows} />
26
29
  </div>
27
30
  <SelectPageButtons table={table} />
28
31
  </div>
@@ -30,18 +33,17 @@ export function Pagination({
30
33
  );
31
34
  }
32
35
 
33
- function PageIndicator({ table }: PaginationProps) {
34
- if (table.getRowModel().rows.length <= 1) {
36
+ function PageIndicator({ table, totalRows }: PaginationProps & { totalRows: number }) {
37
+ if (table.getPaginationRowModel().rows.length <= 1 && totalRows <= 1) {
35
38
  return null;
36
39
  }
37
40
 
38
41
  const minRow = table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1;
39
- const maxRow = minRow + table.getRowModel().rows.length - 1;
40
- const numRows = table.getCoreRowModel().rows.length;
42
+ const maxRow = minRow + table.getPaginationRowModel().rows.length - 1;
41
43
 
42
44
  return (
43
45
  <span className='text-sm'>
44
- {minRow} - {maxRow} of {numRows}
46
+ {minRow} - {maxRow} of {totalRows}
45
47
  </span>
46
48
  );
47
49
  }
@@ -88,8 +90,8 @@ function PageSizeSelector({
88
90
  );
89
91
  }
90
92
 
91
- function GotoPageSelector({ table }: PaginationProps) {
92
- if (table.getRowModel().rows.length === 0) {
93
+ function GotoPageSelector({ table, totalRows }: PaginationProps & { totalRows: number }) {
94
+ if (table.getRowModel().rows.length === 0 && totalRows === 0) {
93
95
  return null;
94
96
  }
95
97
 
@@ -4,8 +4,6 @@ import { useEffect, useState } from 'preact/hooks';
4
4
 
5
5
  import { usePageSizeContext } from './pagination-context';
6
6
 
7
- export * from '@tanstack/table-core';
8
-
9
7
  export type Renderable<TProps> = VNode<TProps> | ComponentType<TProps> | undefined | null | string | number | boolean;
10
8
 
11
9
  /*
@@ -45,12 +43,17 @@ export function usePreactTable<TData extends RowData>(options: TableOptions<TDat
45
43
  },
46
44
  }));
47
45
 
46
+ // When pagination is controlled externally (manualPagination: true with a state override),
47
+ // the caller manages pageSize themselves — skip the context sync to avoid conflicts.
48
+ const isControlled = options.manualPagination === true && options.state?.pagination !== undefined;
48
49
  const { pageSize } = usePageSizeContext();
49
50
  useEffect(
50
51
  () => {
51
- tableRef.current.setPageSize(pageSize);
52
+ if (!isControlled) {
53
+ tableRef.current.setPageSize(pageSize);
54
+ }
52
55
  },
53
- [pageSize], // eslint-disable-line react-hooks/exhaustive-deps -- only run this when the pageSize changes
56
+ [pageSize, isControlled], // eslint-disable-line react-hooks/exhaustive-deps -- only run this when the pageSize changes
54
57
  );
55
58
 
56
59
  return tableRef.current;
@@ -28,7 +28,7 @@ import { type MutationOverTimeDataMap } from '../../mutationsOverTime/MutationOv
28
28
  import {
29
29
  type MutationFilter,
30
30
  mutationOrAnnotationDoNotMatchFilter,
31
- } from '../../mutationsOverTime/getFilteredMutationsOverTimeData';
31
+ } from '../../mutationsOverTime/getFilteredMutationCodes';
32
32
  import { MutationsOverTimeGridTooltip } from '../../mutationsOverTime/mutations-over-time-grid-tooltip';
33
33
  import { pageSizesSchema } from '../../shared/tanstackTable/pagination';
34
34
  import { PageSizeContextProvider } from '../../shared/tanstackTable/pagination-context';