@genspectrum/dashboard-components 1.9.0 → 1.9.2

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.
package/dist/util.d.ts CHANGED
@@ -1005,7 +1005,7 @@ declare global {
1005
1005
 
1006
1006
  declare global {
1007
1007
  interface HTMLElementTagNameMap {
1008
- 'gs-aggregate': AggregateComponent;
1008
+ 'gs-relative-growth-advantage': RelativeGrowthAdvantageComponent;
1009
1009
  }
1010
1010
  }
1011
1011
 
@@ -1013,7 +1013,7 @@ declare global {
1013
1013
  declare global {
1014
1014
  namespace JSX {
1015
1015
  interface IntrinsicElements {
1016
- 'gs-aggregate': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1016
+ 'gs-relative-growth-advantage': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1017
1017
  }
1018
1018
  }
1019
1019
  }
@@ -1021,7 +1021,7 @@ declare global {
1021
1021
 
1022
1022
  declare global {
1023
1023
  interface HTMLElementTagNameMap {
1024
- 'gs-number-sequences-over-time': NumberSequencesOverTimeComponent;
1024
+ 'gs-aggregate': AggregateComponent;
1025
1025
  }
1026
1026
  }
1027
1027
 
@@ -1029,7 +1029,7 @@ declare global {
1029
1029
  declare global {
1030
1030
  namespace JSX {
1031
1031
  interface IntrinsicElements {
1032
- 'gs-number-sequences-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1032
+ 'gs-aggregate': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1033
1033
  }
1034
1034
  }
1035
1035
  }
@@ -1037,7 +1037,7 @@ declare global {
1037
1037
 
1038
1038
  declare global {
1039
1039
  interface HTMLElementTagNameMap {
1040
- 'gs-relative-growth-advantage': RelativeGrowthAdvantageComponent;
1040
+ 'gs-number-sequences-over-time': NumberSequencesOverTimeComponent;
1041
1041
  }
1042
1042
  }
1043
1043
 
@@ -1045,7 +1045,7 @@ declare global {
1045
1045
  declare global {
1046
1046
  namespace JSX {
1047
1047
  interface IntrinsicElements {
1048
- 'gs-relative-growth-advantage': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1048
+ 'gs-number-sequences-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1049
1049
  }
1050
1050
  }
1051
1051
  }
@@ -1053,7 +1053,7 @@ declare global {
1053
1053
 
1054
1054
  declare global {
1055
1055
  interface HTMLElementTagNameMap {
1056
- 'gs-sequences-by-location': SequencesByLocationComponent;
1056
+ 'gs-mutations-over-time': MutationsOverTimeComponent;
1057
1057
  }
1058
1058
  }
1059
1059
 
@@ -1061,7 +1061,7 @@ declare global {
1061
1061
  declare global {
1062
1062
  namespace JSX {
1063
1063
  interface IntrinsicElements {
1064
- 'gs-sequences-by-location': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1064
+ 'gs-mutations-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1065
1065
  }
1066
1066
  }
1067
1067
  }
@@ -1069,7 +1069,7 @@ declare global {
1069
1069
 
1070
1070
  declare global {
1071
1071
  interface HTMLElementTagNameMap {
1072
- 'gs-mutations-over-time': MutationsOverTimeComponent;
1072
+ 'gs-sequences-by-location': SequencesByLocationComponent;
1073
1073
  }
1074
1074
  }
1075
1075
 
@@ -1077,7 +1077,7 @@ declare global {
1077
1077
  declare global {
1078
1078
  namespace JSX {
1079
1079
  interface IntrinsicElements {
1080
- 'gs-mutations-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1080
+ 'gs-sequences-by-location': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1081
1081
  }
1082
1082
  }
1083
1083
  }
@@ -1101,11 +1101,7 @@ declare global {
1101
1101
 
1102
1102
  declare global {
1103
1103
  interface HTMLElementTagNameMap {
1104
- 'gs-date-range-filter': DateRangeFilterComponent;
1105
- }
1106
- interface HTMLElementEventMap {
1107
- [gsEventNames.dateRangeFilterChanged]: CustomEvent<Record<string, string>>;
1108
- [gsEventNames.dateRangeOptionChanged]: DateRangeOptionChangedEvent;
1104
+ 'gs-wastewater-mutations-over-time': WastewaterMutationsOverTimeComponent;
1109
1105
  }
1110
1106
  }
1111
1107
 
@@ -1113,7 +1109,7 @@ declare global {
1113
1109
  declare global {
1114
1110
  namespace JSX {
1115
1111
  interface IntrinsicElements {
1116
- 'gs-date-range-filter': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1112
+ 'gs-wastewater-mutations-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1117
1113
  }
1118
1114
  }
1119
1115
  }
@@ -1121,7 +1117,11 @@ declare global {
1121
1117
 
1122
1118
  declare global {
1123
1119
  interface HTMLElementTagNameMap {
1124
- 'gs-wastewater-mutations-over-time': WastewaterMutationsOverTimeComponent;
1120
+ 'gs-date-range-filter': DateRangeFilterComponent;
1121
+ }
1122
+ interface HTMLElementEventMap {
1123
+ [gsEventNames.dateRangeFilterChanged]: CustomEvent<Record<string, string>>;
1124
+ [gsEventNames.dateRangeOptionChanged]: DateRangeOptionChangedEvent;
1125
1125
  }
1126
1126
  }
1127
1127
 
@@ -1129,7 +1129,7 @@ declare global {
1129
1129
  declare global {
1130
1130
  namespace JSX {
1131
1131
  interface IntrinsicElements {
1132
- 'gs-wastewater-mutations-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1132
+ 'gs-date-range-filter': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
1133
1133
  }
1134
1134
  }
1135
1135
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genspectrum/dashboard-components",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
4
4
  "description": "GenSpectrum web components for building dashboards",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-only",
@@ -7,6 +7,7 @@ import {
7
7
  MutationsOverTimeMutationsFilter,
8
8
  type MutationsOverTimeMutationsFilterProps,
9
9
  } from './mutations-over-time-mutations-filter';
10
+ import { type MutationAnnotations } from '../../web-components/mutation-annotations-context';
10
11
  import { MutationAnnotationsContextProvider } from '../MutationAnnotationsContext';
11
12
  import { type MutationFilter } from '../mutationsOverTime/getFilteredMutationsOverTimeData';
12
13
 
@@ -18,30 +19,38 @@ const meta: Meta = {
18
19
 
19
20
  export default meta;
20
21
 
22
+ const manyMutationAnnotations = Array.from({ length: 300 }, (_, i) => ({
23
+ name: `Annotation ${i + 1}`,
24
+ description: `This is test annotation number ${i + 1} for testing many annotations.`,
25
+ symbol: String.fromCharCode(33 + (i % 94)), // Cycle through printable ASCII characters
26
+ nucleotideMutations: ['A23G'],
27
+ aminoAcidMutations: [],
28
+ })) satisfies MutationAnnotations;
29
+
21
30
  const WrapperWithState = ({
22
31
  setFilterValue,
23
32
  value,
33
+ annotations = [
34
+ {
35
+ name: 'Test Annotation 1',
36
+ description: 'Test Annotation 1',
37
+ symbol: '#',
38
+ },
39
+ {
40
+ name: 'Test Annotation 2',
41
+ description: 'Test Annotation 2',
42
+ symbol: '+',
43
+ },
44
+ ],
24
45
  }: {
25
46
  setFilterValue: Dispatch<StateUpdater<MutationFilter>>;
26
47
  value: MutationFilter;
48
+ annotations?: MutationAnnotations;
27
49
  }) => {
28
50
  const [state, setState] = useState(value);
29
51
 
30
52
  return (
31
- <MutationAnnotationsContextProvider
32
- value={[
33
- {
34
- name: 'Test Annotation 1',
35
- description: 'Test Annotation 1',
36
- symbol: '#',
37
- },
38
- {
39
- name: 'Test Annotation 2',
40
- description: 'Test Annotation 2',
41
- symbol: '+',
42
- },
43
- ]}
44
- >
53
+ <MutationAnnotationsContextProvider value={annotations}>
45
54
  <MutationsOverTimeMutationsFilter
46
55
  setFilterValue={(value) => {
47
56
  setFilterValue(value);
@@ -107,3 +116,53 @@ export const FilterByAnnotation: StoryObj<MutationsOverTimeMutationsFilterProps>
107
116
  });
108
117
  },
109
118
  };
119
+
120
+ export const WithManyMutationAnnotations: StoryObj<MutationsOverTimeMutationsFilterProps> = {
121
+ render: (args) => {
122
+ return (
123
+ <WrapperWithState
124
+ setFilterValue={args.setFilterValue}
125
+ value={args.value}
126
+ annotations={manyMutationAnnotations}
127
+ />
128
+ );
129
+ },
130
+ args: {
131
+ setFilterValue: fn(),
132
+ value: { textFilter: '', annotationNameFilter: new Set() },
133
+ },
134
+ play: async ({ canvasElement, step }) => {
135
+ const canvas = within(canvasElement);
136
+
137
+ await step('Open filter dropdown', async () => {
138
+ const filterButton = canvas.getByRole('button', { name: 'Filter mutations' });
139
+ await userEvent.click(filterButton);
140
+ });
141
+
142
+ await step('Verify scroll container is scrollable', () => {
143
+ const scrollContainer = canvas
144
+ .getByText('Filter by annotations')
145
+ .parentElement!.querySelector('.overflow-scroll')!;
146
+ void expect(scrollContainer).toBeInTheDocument();
147
+
148
+ // Verify the container has scrollable content
149
+ void expect(scrollContainer.scrollHeight).toBeGreaterThan(scrollContainer.clientHeight);
150
+ });
151
+
152
+ await step('Scroll to bottom and verify we can scroll', async () => {
153
+ const scrollContainer = canvas
154
+ .getByText('Filter by annotations')
155
+ .parentElement!.querySelector('.overflow-scroll')!;
156
+
157
+ const initialScrollTop = scrollContainer.scrollTop;
158
+
159
+ // Scroll to the bottom
160
+ scrollContainer.scrollTop = scrollContainer.scrollHeight;
161
+
162
+ await waitFor(async () => {
163
+ // Verify that scrollTop actually changed
164
+ await expect(scrollContainer.scrollTop).toBeGreaterThan(initialScrollTop);
165
+ });
166
+ });
167
+ },
168
+ };
@@ -105,34 +105,36 @@ const AnnotationCheckboxes: FunctionComponent<MutationsOverTimeMutationsFilterPr
105
105
  <div className='divider mt-0.5 mb-0' />
106
106
  <div className='text-sm'>
107
107
  <div className='font-bold mb-1'>Filter by annotations</div>
108
- {mutationAnnotations.map((annotation, index) => (
109
- <li className='flex flex-row items-center' key={annotation.name}>
110
- <label>
111
- <input
112
- className={'mr-2'}
113
- type='checkbox'
114
- id={`item-${index}`}
115
- checked={value.annotationNameFilter.has(annotation.name)}
116
- onChange={() => {
117
- setFilterValue((previousFilter) => {
118
- const newAnnotationFilter = previousFilter.annotationNameFilter.has(
119
- annotation.name,
120
- )
121
- ? [...previousFilter.annotationNameFilter].filter(
122
- (name) => name !== annotation.name,
123
- )
124
- : [...previousFilter.annotationNameFilter, annotation.name];
125
- return {
126
- ...previousFilter,
127
- annotationNameFilter: new Set(newAnnotationFilter),
128
- };
129
- });
130
- }}
131
- />
132
- {annotation.name} (<span className='text-red-600'>{annotation.symbol}</span>)
133
- </label>
134
- </li>
135
- ))}
108
+ <div className='max-h-72 overflow-scroll'>
109
+ {mutationAnnotations.map((annotation, index) => (
110
+ <li className='flex flex-row items-center' key={annotation.name}>
111
+ <label>
112
+ <input
113
+ className={'mr-2'}
114
+ type='checkbox'
115
+ id={`item-${index}`}
116
+ checked={value.annotationNameFilter.has(annotation.name)}
117
+ onChange={() => {
118
+ setFilterValue((previousFilter) => {
119
+ const newAnnotationFilter = previousFilter.annotationNameFilter.has(
120
+ annotation.name,
121
+ )
122
+ ? [...previousFilter.annotationNameFilter].filter(
123
+ (name) => name !== annotation.name,
124
+ )
125
+ : [...previousFilter.annotationNameFilter, annotation.name];
126
+ return {
127
+ ...previousFilter,
128
+ annotationNameFilter: new Set(newAnnotationFilter),
129
+ };
130
+ });
131
+ }}
132
+ />
133
+ {annotation.name} (<span className='text-red-600'>{annotation.symbol}</span>)
134
+ </label>
135
+ </li>
136
+ ))}
137
+ </div>
136
138
  </div>
137
139
  </>
138
140
  );
@@ -321,4 +321,76 @@ describe('fetchLineageAutocompleteList', () => {
321
321
  },
322
322
  ]);
323
323
  });
324
+
325
+ test('should include prefix aliases that are missing from lineage tree', async () => {
326
+ lapisRequestMocks.aggregated(
327
+ { fields: [lineageField], ...lapisFilter },
328
+ {
329
+ data: [
330
+ {
331
+ [lineageField]: 'BA.3.2.1',
332
+ count: 1,
333
+ },
334
+ {
335
+ [lineageField]: 'BA.3.2.2',
336
+ count: 2,
337
+ },
338
+ ],
339
+ },
340
+ );
341
+
342
+ lapisRequestMocks.lineageDefinition(
343
+ {
344
+ 'B.1.1.529.3.2': {
345
+ aliases: ['BA.3.2'],
346
+ },
347
+ 'BA.3.2.1': {
348
+ parents: ['B.1.1.529.3.2'],
349
+ aliases: ['B.1.1.529.3.2.1'],
350
+ },
351
+ 'BA.3.2.2': {
352
+ parents: ['B.1.1.529.3.2'],
353
+ aliases: ['B.1.1.529.3.2.2'],
354
+ },
355
+ },
356
+ lineageField,
357
+ );
358
+
359
+ const result = await fetchLineageAutocompleteList({
360
+ lapisUrl: DUMMY_LAPIS_URL,
361
+ lapisField: lineageField,
362
+ lapisFilter,
363
+ });
364
+
365
+ expect(result).to.deep.equal([
366
+ {
367
+ lineage: 'B.1.1.529.3.2',
368
+ count: 0,
369
+ },
370
+ {
371
+ lineage: 'B.1.1.529.3.2*',
372
+ count: 3,
373
+ },
374
+ {
375
+ lineage: 'BA.3.2*',
376
+ count: 3, // Same as B.1.1.529.3.2* (includes .3.2 and .3.2.1)
377
+ },
378
+ {
379
+ lineage: 'BA.3.2.1',
380
+ count: 1,
381
+ },
382
+ {
383
+ lineage: 'BA.3.2.1*',
384
+ count: 1,
385
+ },
386
+ {
387
+ lineage: 'BA.3.2.2',
388
+ count: 2,
389
+ },
390
+ {
391
+ lineage: 'BA.3.2.2*',
392
+ count: 2,
393
+ },
394
+ ]);
395
+ });
324
396
  });
@@ -2,6 +2,10 @@ import { fetchLineageDefinition } from '../../lapisApi/lapisApi';
2
2
  import { FetchAggregatedOperator } from '../../operator/FetchAggregatedOperator';
3
3
  import type { LapisFilter } from '../../types';
4
4
 
5
+ /**
6
+ * Generates the autocomplete list for lineage search. It includes lineages with wild cards
7
+ * (i.e. "BA.3.2.1" and "BA.3.2.1*") as well as all prefixes of lineages with an asterisk ("BA.3.2*").
8
+ */
5
9
  export async function fetchLineageAutocompleteList({
6
10
  lapisUrl,
7
11
  lapisField,
@@ -13,31 +17,43 @@ export async function fetchLineageAutocompleteList({
13
17
  lapisFilter?: LapisFilter;
14
18
  signal?: AbortSignal;
15
19
  }): Promise<LineageItem[]> {
16
- const [countsByLineage, lineageTree] = await Promise.all([
20
+ const [countsByLineage, { lineageTree, aliasMapping }] = await Promise.all([
17
21
  getCountsByLineage({
18
22
  lapisUrl,
19
23
  lapisField,
20
24
  lapisFilter,
21
25
  signal,
22
26
  }),
23
- getLineageTree({ lapisUrl, lapisField, signal }),
27
+ getLineageTreeAndAliases({ lapisUrl, lapisField, signal }),
24
28
  ]);
25
29
 
26
- return Array.from(lineageTree.keys())
27
- .sort((a, b) => a.localeCompare(b))
28
- .map((lineage) => {
29
- return [
30
- {
31
- lineage,
32
- count: countsByLineage.get(lineage) ?? 0,
33
- },
34
- {
35
- lineage: `${lineage}*`,
36
- count: getCountsIncludingSublineages(lineage, lineageTree, countsByLineage),
37
- },
38
- ];
39
- })
40
- .flat();
30
+ const prefixToLineage = findMissingPrefixMappings(lineageTree, aliasMapping);
31
+
32
+ // Combine actual lineages with their wildcard versions
33
+ const actualLineageItems = Array.from(lineageTree.keys()).flatMap((lineage) => [
34
+ {
35
+ lineage,
36
+ count: countsByLineage.get(lineage) ?? 0,
37
+ },
38
+ {
39
+ lineage: `${lineage}*`,
40
+ count: getCountsIncludingSublineages(lineage, lineageTree, countsByLineage),
41
+ },
42
+ ]);
43
+
44
+ // Add prefix alias items with wildcard and their counts
45
+ const prefixAliasItems = Array.from(prefixToLineage.entries()).map(([prefix, actualLineage]) => ({
46
+ lineage: `${prefix}*`,
47
+ count: getCountsIncludingSublineages(actualLineage, lineageTree, countsByLineage),
48
+ }));
49
+
50
+ // Combine and sort all items (asterisk before period for same prefix)
51
+ return [...actualLineageItems, ...prefixAliasItems].sort((a, b) => {
52
+ // Replace * with a character that sorts before . in ASCII
53
+ const aKey = a.lineage.replace(/\*/g, ' ');
54
+ const bKey = b.lineage.replace(/\*/g, ' ');
55
+ return aKey.localeCompare(bKey);
56
+ });
41
57
  }
42
58
 
43
59
  export type LineageItem = { lineage: string; count: number };
@@ -61,7 +77,7 @@ async function getCountsByLineage({
61
77
  return new Map<string, number>(countsByLineageArray.map((value) => [value[lapisField], value.count]));
62
78
  }
63
79
 
64
- async function getLineageTree({
80
+ async function getLineageTreeAndAliases({
65
81
  lapisUrl,
66
82
  lapisField,
67
83
  signal,
@@ -73,12 +89,17 @@ async function getLineageTree({
73
89
  const lineageDefinitions = await fetchLineageDefinition({ lapisUrl, lapisField, signal });
74
90
 
75
91
  const lineageTree = new Map<string, { children: string[] }>();
92
+ const aliasMapping = new Map<string, string[]>();
76
93
 
77
94
  Object.entries(lineageDefinitions).forEach(([lineage, definition]) => {
78
95
  if (!lineageTree.has(lineage)) {
79
96
  lineageTree.set(lineage, { children: [] });
80
97
  }
81
98
 
99
+ if (definition.aliases && definition.aliases.length > 0) {
100
+ aliasMapping.set(lineage, definition.aliases);
101
+ }
102
+
82
103
  definition.parents?.forEach((parent) => {
83
104
  const parentChildren = lineageTree.get(parent)?.children;
84
105
 
@@ -88,7 +109,7 @@ async function getLineageTree({
88
109
  });
89
110
  });
90
111
 
91
- return lineageTree;
112
+ return { lineageTree, aliasMapping };
92
113
  }
93
114
 
94
115
  function getCountsIncludingSublineages(
@@ -115,3 +136,44 @@ function getAllDescendants(lineage: string, lineageTree: Map<string, { children:
115
136
 
116
137
  return new Set([...children, ...childrenOfChildren.flatMap((child) => Array.from(child))]);
117
138
  }
139
+
140
+ /**
141
+ * This function finds prefixes (i.e. "BA.3.2" for "BA.3.2.1") that are not in the lineageTree,
142
+ * but do appear as an alias. It returns a reverse mapping for those prefixes, back to a lineage
143
+ * that can be found in the lineageTree (i.e. "BA.3.2" -> "B.1.1.529.3.2").
144
+ */
145
+ function findMissingPrefixMappings(
146
+ lineageTree: Map<string, { children: string[] }>,
147
+ aliasMapping: Map<string, string[]>,
148
+ ): Map<string, string> {
149
+ const lineages = Array.from(lineageTree.keys());
150
+ const lineagesSet = new Set(lineages);
151
+
152
+ // Generate all prefixes for each lineage (e.g., "A.B.1" -> ["A", "A.B", "A.B.1"])
153
+ const allPrefixes = lineages.flatMap((lineage) => {
154
+ const parts = lineage.split('.');
155
+ return parts.map((_, i) => parts.slice(0, i + 1).join('.'));
156
+ });
157
+
158
+ // Find prefixes that are NOT in the actual lineages list
159
+ const missingPrefixes = new Set(allPrefixes.filter((prefix) => !lineagesSet.has(prefix)));
160
+
161
+ // Create reverse alias mapping: alias -> original lineage
162
+ const reverseAliasMapping = new Map<string, string>();
163
+ aliasMapping.forEach((aliases, lineage) => {
164
+ aliases.forEach((alias) => {
165
+ reverseAliasMapping.set(alias, lineage);
166
+ });
167
+ });
168
+
169
+ // Map missing prefixes to their actual lineage names via reverse alias lookup
170
+ const prefixToLineage = new Map<string, string>();
171
+ missingPrefixes.forEach((prefix) => {
172
+ const actualLineage = reverseAliasMapping.get(prefix);
173
+ if (actualLineage) {
174
+ prefixToLineage.set(prefix, actualLineage);
175
+ }
176
+ });
177
+
178
+ return prefixToLineage;
179
+ }