@genspectrum/dashboard-components 0.19.7 → 0.19.9

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 (29) hide show
  1. package/custom-elements.json +33 -15
  2. package/dist/assets/mutationOverTimeWorker-BzmkceEA.js.map +1 -0
  3. package/dist/components.d.ts +24 -24
  4. package/dist/components.js +121 -21
  5. package/dist/components.js.map +1 -1
  6. package/dist/util.d.ts +24 -24
  7. package/package.json +1 -1
  8. package/src/lapisApi/LineageDefinition.ts +9 -0
  9. package/src/lapisApi/lapisApi.ts +27 -0
  10. package/src/preact/lineageFilter/__mockData__/lineageDefinition.json +38118 -0
  11. package/src/preact/lineageFilter/fetchLineageAutocompleteList.spec.ts +264 -6
  12. package/src/preact/lineageFilter/fetchLineageAutocompleteList.ts +104 -7
  13. package/src/preact/lineageFilter/lineage-filter.stories.tsx +13 -1
  14. package/src/preact/lineageFilter/lineage-filter.tsx +21 -11
  15. package/src/web-components/input/gs-lineage-filter.stories.ts +13 -1
  16. package/src/web-components/input/introduction.mdx +57 -2
  17. package/src/web-components/tutorials/CreateYourFirstOwnDashboard.mdx +85 -0
  18. package/src/web-components/tutorials/UseTheComponentsWithPlainJavaScript.mdx +140 -0
  19. package/src/web-components/tutorials/UseTheComponentsWithReact.mdx +166 -0
  20. package/src/web-components/visualization/gs-mutations.tsx +2 -2
  21. package/src/web-components/visualization/introduction.mdx +51 -0
  22. package/standalone-bundle/assets/mutationOverTimeWorker-jUeItsGM.js.map +1 -0
  23. package/standalone-bundle/dashboard-components.js +7007 -6953
  24. package/standalone-bundle/dashboard-components.js.map +1 -1
  25. package/dist/assets/mutationOverTimeWorker-DQGh08AS.js.map +0 -1
  26. package/src/web-components/visualization/data_visualization_statistical_analysis.mdx +0 -26
  27. package/standalone-bundle/assets/mutationOverTimeWorker-DAf2_NiP.js.map +0 -1
  28. /package/src/web-components/{MutationAnnotations.mdx → mutationAnnotations.mdx} +0 -0
  29. /package/src/web-components/{ResizeContainer.mdx → sizeOfComponents.mdx} +0 -0
@@ -4,25 +4,283 @@ import { fetchLineageAutocompleteList } from './fetchLineageAutocompleteList';
4
4
  import { DUMMY_LAPIS_URL, lapisRequestMocks } from '../../../vitest.setup';
5
5
 
6
6
  describe('fetchLineageAutocompleteList', () => {
7
+ const lapisFilter = { country: 'Germany' };
8
+ const lineageField = 'lineageField';
9
+
10
+ test('should return single lineage', async () => {
11
+ lapisRequestMocks.aggregated(
12
+ { fields: [lineageField], ...lapisFilter },
13
+ {
14
+ data: [
15
+ {
16
+ [lineageField]: 'A',
17
+ count: 1,
18
+ },
19
+ ],
20
+ },
21
+ );
22
+
23
+ lapisRequestMocks.lineageDefinition(
24
+ {
25
+ A: {
26
+ aliases: ['a'],
27
+ },
28
+ },
29
+ lineageField,
30
+ );
31
+
32
+ const result = await fetchLineageAutocompleteList({
33
+ lapisUrl: DUMMY_LAPIS_URL,
34
+ lapisField: lineageField,
35
+ lapisFilter,
36
+ });
37
+
38
+ expect(result).to.deep.equal([
39
+ {
40
+ lineage: 'A',
41
+ count: 1,
42
+ },
43
+ {
44
+ lineage: 'A*',
45
+ count: 1,
46
+ },
47
+ ]);
48
+ });
49
+
7
50
  test('should add sublineage values', async () => {
8
51
  lapisRequestMocks.aggregated(
9
- { fields: ['lineageField'], country: 'Germany' },
52
+ { fields: [lineageField], ...lapisFilter },
10
53
  {
11
54
  data: [
12
55
  {
13
- lineageField: 'B.1.1.7',
56
+ [lineageField]: 'A',
14
57
  count: 1,
15
58
  },
59
+ {
60
+ [lineageField]: 'A.1',
61
+ count: 2,
62
+ },
63
+ {
64
+ [lineageField]: 'A.2',
65
+ count: 3,
66
+ },
16
67
  ],
17
68
  },
18
69
  );
19
70
 
71
+ lapisRequestMocks.lineageDefinition(
72
+ {
73
+ A: {
74
+ aliases: ['a'],
75
+ },
76
+ 'A.1': {
77
+ parents: ['A'],
78
+ aliases: ['a.1'],
79
+ },
80
+ 'A.2': {
81
+ parents: ['A'],
82
+ aliases: ['a.1'],
83
+ },
84
+ },
85
+ lineageField,
86
+ );
87
+
20
88
  const result = await fetchLineageAutocompleteList({
21
- lapis: DUMMY_LAPIS_URL,
22
- field: 'lineageField',
23
- lapisFilter: { country: 'Germany' },
89
+ lapisUrl: DUMMY_LAPIS_URL,
90
+ lapisField: lineageField,
91
+ lapisFilter,
24
92
  });
25
93
 
26
- expect(result).to.deep.equal(['B.1.1.7', 'B.1.1.7*']);
94
+ expect(result).to.deep.equal([
95
+ {
96
+ lineage: 'A',
97
+ count: 1,
98
+ },
99
+ {
100
+ lineage: 'A*',
101
+ count: 6,
102
+ },
103
+
104
+ {
105
+ lineage: 'A.1',
106
+ count: 2,
107
+ },
108
+ {
109
+ lineage: 'A.1*',
110
+ count: 2,
111
+ },
112
+
113
+ {
114
+ lineage: 'A.2',
115
+ count: 3,
116
+ },
117
+ {
118
+ lineage: 'A.2*',
119
+ count: 3,
120
+ },
121
+ ]);
122
+ });
123
+
124
+ test('should work with recombinations', async () => {
125
+ lapisRequestMocks.aggregated(
126
+ { fields: [lineageField], ...lapisFilter },
127
+ {
128
+ data: [
129
+ {
130
+ [lineageField]: 'A',
131
+ count: 1,
132
+ },
133
+ {
134
+ [lineageField]: 'A.1',
135
+ count: 2,
136
+ },
137
+ {
138
+ [lineageField]: 'A.2',
139
+ count: 3,
140
+ },
141
+ {
142
+ [lineageField]: 'XA',
143
+ count: 4,
144
+ },
145
+ ],
146
+ },
147
+ );
148
+
149
+ lapisRequestMocks.lineageDefinition(
150
+ {
151
+ A: {
152
+ aliases: ['a'],
153
+ },
154
+ 'A.1': {
155
+ parents: ['A'],
156
+ aliases: ['a.1'],
157
+ },
158
+ 'A.2': {
159
+ parents: ['A'],
160
+ aliases: ['a.1'],
161
+ },
162
+ XA: {
163
+ aliases: ['xa'],
164
+ parents: ['A.1', 'A.2'],
165
+ },
166
+ },
167
+ lineageField,
168
+ );
169
+
170
+ const result = await fetchLineageAutocompleteList({
171
+ lapisUrl: DUMMY_LAPIS_URL,
172
+ lapisField: lineageField,
173
+ lapisFilter,
174
+ });
175
+
176
+ expect(result).to.deep.equal([
177
+ {
178
+ lineage: 'A',
179
+ count: 1,
180
+ },
181
+ {
182
+ lineage: 'A*',
183
+ count: 10,
184
+ },
185
+
186
+ {
187
+ lineage: 'A.1',
188
+ count: 2,
189
+ },
190
+ {
191
+ lineage: 'A.1*',
192
+ count: 6,
193
+ },
194
+ {
195
+ lineage: 'A.2',
196
+ count: 3,
197
+ },
198
+ {
199
+ lineage: 'A.2*',
200
+ count: 7,
201
+ },
202
+ {
203
+ lineage: 'XA',
204
+ count: 4,
205
+ },
206
+ {
207
+ lineage: 'XA*',
208
+ count: 4,
209
+ },
210
+ ]);
211
+ });
212
+
213
+ test('should work with grandchildren', async () => {
214
+ lapisRequestMocks.aggregated(
215
+ { fields: [lineageField], ...lapisFilter },
216
+ {
217
+ data: [
218
+ {
219
+ [lineageField]: 'A',
220
+ count: 1,
221
+ },
222
+ {
223
+ [lineageField]: 'A.1',
224
+ count: 2,
225
+ },
226
+ {
227
+ [lineageField]: 'A.1.1',
228
+ count: 3,
229
+ },
230
+ ],
231
+ },
232
+ );
233
+
234
+ lapisRequestMocks.lineageDefinition(
235
+ {
236
+ 'A.1': {
237
+ parents: ['A'],
238
+ aliases: ['a.1'],
239
+ },
240
+ A: {
241
+ aliases: ['a'],
242
+ },
243
+ 'A.1.1': {
244
+ parents: ['A.1'],
245
+ aliases: ['a.1.1'],
246
+ },
247
+ },
248
+ lineageField,
249
+ );
250
+
251
+ const result = await fetchLineageAutocompleteList({
252
+ lapisUrl: DUMMY_LAPIS_URL,
253
+ lapisField: lineageField,
254
+ lapisFilter,
255
+ });
256
+
257
+ expect(result).to.deep.equal([
258
+ {
259
+ lineage: 'A',
260
+ count: 1,
261
+ },
262
+ {
263
+ lineage: 'A*',
264
+ count: 6,
265
+ },
266
+
267
+ {
268
+ lineage: 'A.1',
269
+ count: 2,
270
+ },
271
+ {
272
+ lineage: 'A.1*',
273
+ count: 5,
274
+ },
275
+
276
+ {
277
+ lineage: 'A.1.1',
278
+ count: 3,
279
+ },
280
+ {
281
+ lineage: 'A.1.1*',
282
+ count: 3,
283
+ },
284
+ ]);
27
285
  });
28
286
  });
@@ -1,20 +1,117 @@
1
+ import { fetchLineageDefinition } from '../../lapisApi/lapisApi';
1
2
  import { FetchAggregatedOperator } from '../../operator/FetchAggregatedOperator';
2
3
  import type { LapisFilter } from '../../types';
3
4
 
4
5
  export async function fetchLineageAutocompleteList({
5
- lapis,
6
- field,
6
+ lapisUrl,
7
+ lapisField,
8
+ lapisFilter,
7
9
  signal,
10
+ }: {
11
+ lapisUrl: string;
12
+ lapisField: string;
13
+ lapisFilter?: LapisFilter;
14
+ signal?: AbortSignal;
15
+ }): Promise<LineageItem[]> {
16
+ const [countsByLineage, lineageTree] = await Promise.all([
17
+ getCountsByLineage({
18
+ lapisUrl,
19
+ lapisField,
20
+ lapisFilter,
21
+ signal,
22
+ }),
23
+ getLineageTree({ lapisUrl, lapisField, signal }),
24
+ ]);
25
+
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();
41
+ }
42
+
43
+ export type LineageItem = { lineage: string; count: number };
44
+
45
+ async function getCountsByLineage({
46
+ lapisUrl,
47
+ lapisField,
8
48
  lapisFilter,
49
+ signal,
9
50
  }: {
10
- lapis: string;
11
- field: string;
51
+ lapisUrl: string;
52
+ lapisField: string;
12
53
  lapisFilter?: LapisFilter;
13
54
  signal?: AbortSignal;
14
55
  }) {
15
- const fetchAggregatedOperator = new FetchAggregatedOperator<Record<string, string>>(lapisFilter ?? {}, [field]);
56
+ const fetchAggregatedOperator = new FetchAggregatedOperator<Record<string, string>>(lapisFilter ?? {}, [
57
+ lapisField,
58
+ ]);
59
+
60
+ const countsByLineageArray = (await fetchAggregatedOperator.evaluate(lapisUrl, signal)).content;
61
+ return new Map<string, number>(countsByLineageArray.map((value) => [value[lapisField], value.count]));
62
+ }
63
+
64
+ async function getLineageTree({
65
+ lapisUrl,
66
+ lapisField,
67
+ signal,
68
+ }: {
69
+ lapisUrl: string;
70
+ lapisField: string;
71
+ signal?: AbortSignal;
72
+ }) {
73
+ const lineageDefinitions = await fetchLineageDefinition({ lapisUrl, lapisField, signal });
74
+
75
+ const lineageTree = new Map<string, { children: string[] }>();
76
+
77
+ Object.entries(lineageDefinitions).forEach(([lineage, definition]) => {
78
+ if (!lineageTree.has(lineage)) {
79
+ lineageTree.set(lineage, { children: [] });
80
+ }
81
+
82
+ definition.parents?.forEach((parent) => {
83
+ const parentChildren = lineageTree.get(parent)?.children;
84
+
85
+ const newParentChildren = parentChildren ? [...parentChildren, lineage] : [lineage];
86
+
87
+ lineageTree.set(parent, { children: newParentChildren });
88
+ });
89
+ });
90
+
91
+ return lineageTree;
92
+ }
93
+
94
+ function getCountsIncludingSublineages(
95
+ lineage: string,
96
+ lineageTree: Map<string, { children: string[] }>,
97
+ countsByLineage: Map<string, number>,
98
+ ): number {
99
+ const descendants = getAllDescendants(lineage, lineageTree);
100
+
101
+ const countOfChildren = [...descendants].reduce((sum, child) => {
102
+ return sum + (countsByLineage.get(child) ?? 0);
103
+ }, 0);
104
+ const countLineage = countsByLineage.get(lineage) ?? 0;
105
+
106
+ return countOfChildren + countLineage;
107
+ }
108
+
109
+ function getAllDescendants(lineage: string, lineageTree: Map<string, { children: string[] }>): Set<string> {
110
+ const children = lineageTree.get(lineage)?.children ?? [];
16
111
 
17
- const data = (await fetchAggregatedOperator.evaluate(lapis, signal)).content;
112
+ const childrenOfChildren = children.flatMap((child) => {
113
+ return getAllDescendants(child, lineageTree);
114
+ });
18
115
 
19
- return data.flatMap((item) => [item[field], `${item[field]}*`]).sort();
116
+ return new Set([...children, ...childrenOfChildren.flatMap((child) => Array.from(child))]);
20
117
  }
@@ -5,6 +5,8 @@ import type { StepFunction } from '@storybook/types';
5
5
  import { LineageFilter, type LineageFilterProps } from './lineage-filter';
6
6
  import { previewHandles } from '../../../.storybook/preview';
7
7
  import { AGGREGATED_ENDPOINT, LAPIS_URL } from '../../constants';
8
+ import lineageDefinition from './__mockData__/lineageDefinition.json';
9
+ import { lineageDefinitionEndpoint } from '../../lapisApi/lapisApi';
8
10
  import aggregatedData from '../../preact/lineageFilter/__mockData__/aggregated.json';
9
11
  import { gsEventNames } from '../../utils/gsEventNames';
10
12
  import { LapisUrlContextProvider } from '../LapisUrlContext';
@@ -33,6 +35,16 @@ const meta: Meta = {
33
35
  body: aggregatedData,
34
36
  },
35
37
  },
38
+ {
39
+ matcher: {
40
+ name: 'lineageDefinition',
41
+ url: lineageDefinitionEndpoint(LAPIS_URL, 'pangoLineage'),
42
+ },
43
+ response: {
44
+ status: 200,
45
+ body: lineageDefinition,
46
+ },
47
+ },
36
48
  ],
37
49
  },
38
50
  },
@@ -90,7 +102,7 @@ export const Default: StoryObj<LineageFilterProps> = {
90
102
  const input = await inputField(canvas);
91
103
  await userEvent.clear(input);
92
104
  await userEvent.type(input, 'B.1');
93
- await userEvent.click(canvas.getByRole('option', { name: 'B.1' }));
105
+ await userEvent.click(canvas.getByRole('option', { name: 'B.1(53802)' }));
94
106
 
95
107
  await waitFor(() => {
96
108
  return expect(lineageChangedListenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
@@ -1,9 +1,10 @@
1
1
  import { type FunctionComponent } from 'preact';
2
+ import { useMemo } from 'preact/hooks';
2
3
  import z from 'zod';
3
4
 
4
5
  import { useLapisUrl } from '../LapisUrlContext';
5
6
  import { LineageFilterChangedEvent } from './LineageFilterChangedEvent';
6
- import { fetchLineageAutocompleteList } from './fetchLineageAutocompleteList';
7
+ import { fetchLineageAutocompleteList, type LineageItem } from './fetchLineageAutocompleteList';
7
8
  import { lapisFilterSchema } from '../../types';
8
9
  import { DownshiftCombobox } from '../components/downshift-combobox';
9
10
  import { ErrorBoundary } from '../components/error-boundary';
@@ -46,11 +47,11 @@ const LineageFilterInner: FunctionComponent<LineageFilterInnerProps> = ({
46
47
  value,
47
48
  lapisFilter,
48
49
  }) => {
49
- const lapis = useLapisUrl();
50
+ const lapisUrl = useLapisUrl();
50
51
 
51
52
  const { data, error, isLoading } = useQuery(
52
- () => fetchLineageAutocompleteList({ lapis, field: lapisField, lapisFilter }),
53
- [lapisField, lapis, lapisFilter],
53
+ () => fetchLineageAutocompleteList({ lapisUrl, lapisField, lapisFilter }),
54
+ [lapisField, lapisUrl, lapisFilter],
54
55
  );
55
56
 
56
57
  if (isLoading) {
@@ -70,24 +71,33 @@ const LineageSelector = ({
70
71
  placeholderText,
71
72
  data,
72
73
  }: LineageSelectorProps & {
73
- data: string[];
74
+ data: LineageItem[];
74
75
  }) => {
76
+ const selectedItem = useMemo(() => {
77
+ return data.find((item) => item.lineage === value) ?? null;
78
+ }, [data, value]);
79
+
75
80
  return (
76
81
  <DownshiftCombobox
77
82
  allItems={data}
78
- value={value}
83
+ value={selectedItem}
79
84
  filterItemsByInputValue={filterByInputValue}
80
- createEvent={(item) => new LineageFilterChangedEvent({ [lapisField]: item ?? undefined })}
81
- itemToString={(item) => item ?? ''}
85
+ createEvent={(item) => new LineageFilterChangedEvent({ [lapisField]: item?.lineage ?? undefined })}
86
+ itemToString={(item) => item?.lineage ?? ''}
82
87
  placeholderText={placeholderText}
83
- formatItemInList={(item: string) => item}
88
+ formatItemInList={(item: LineageItem) => (
89
+ <p>
90
+ <span>{item.lineage}</span>
91
+ <span className='ml-2 text-gray-500'>({item.count})</span>
92
+ </p>
93
+ )}
84
94
  />
85
95
  );
86
96
  };
87
97
 
88
- function filterByInputValue(item: string, inputValue: string | null) {
98
+ function filterByInputValue(item: LineageItem, inputValue: string | null) {
89
99
  if (inputValue === null || inputValue === '') {
90
100
  return true;
91
101
  }
92
- return item?.toLowerCase().includes(inputValue?.toLowerCase() || '');
102
+ return item.lineage?.toLowerCase().includes(inputValue?.toLowerCase() || '');
93
103
  }
@@ -7,7 +7,9 @@ import { previewHandles } from '../../../.storybook/preview';
7
7
  import { AGGREGATED_ENDPOINT, LAPIS_URL } from '../../constants';
8
8
  import '../gs-app';
9
9
  import './gs-lineage-filter';
10
+ import { lineageDefinitionEndpoint } from '../../lapisApi/lapisApi';
10
11
  import aggregatedData from '../../preact/lineageFilter/__mockData__/aggregated.json';
12
+ import lineageDefinition from '../../preact/lineageFilter/__mockData__/lineageDefinition.json';
11
13
  import { type LineageFilterProps } from '../../preact/lineageFilter/lineage-filter';
12
14
  import { gsEventNames } from '../../utils/gsEventNames';
13
15
  import { withinShadowRoot } from '../withinShadowRoot.story';
@@ -44,6 +46,16 @@ const meta: Meta<Required<LineageFilterProps>> = {
44
46
  body: aggregatedData,
45
47
  },
46
48
  },
49
+ {
50
+ matcher: {
51
+ name: 'lineageDefinition',
52
+ url: lineageDefinitionEndpoint(LAPIS_URL, 'pangoLineage'),
53
+ },
54
+ response: {
55
+ status: 200,
56
+ body: lineageDefinition,
57
+ },
58
+ },
47
59
  ],
48
60
  },
49
61
  componentDocs: {
@@ -210,7 +222,7 @@ export const FiresEvent: StoryObj<Required<LineageFilterProps>> = {
210
222
 
211
223
  await step('Enter a valid lineage value', async () => {
212
224
  await userEvent.type(inputField(), 'B.1.1.7*');
213
- await userEvent.click(canvas.getByRole('option', { name: 'B.1.1.7*' }));
225
+ await userEvent.click(canvas.getByRole('option', { name: 'B.1.1.7*(677146)' }));
214
226
 
215
227
  await waitFor(() => {
216
228
  return expect(listenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
@@ -7,5 +7,60 @@ import { Meta } from '@storybook/blocks';
7
7
  The components in this section let the user specify values for LAPIS filters.
8
8
  The filters can then be used as input to the visualization components.
9
9
 
10
- Every component fires `CustomEvent`s when the user interacts with it, which can be used to update the LAPIS filters.
11
- The `detail` of the event is designed such that it can be directly passed to the LAPIS API.
10
+ Every component fires `CustomEvent`s when the user interacts with it.
11
+ `event.detail` contains the payload of the event.
12
+
13
+ Every component fires an event that can be used to update the LAPIS filters.
14
+ It is supposed to be used in the style of:
15
+
16
+ ```javascript
17
+ component.addEventListener('gs-example-event', (event) => {
18
+ setNewLapisFilter({
19
+ ...previousLapisFilter,
20
+ ...event.detail,
21
+ });
22
+ });
23
+ ```
24
+
25
+ ## Controlled Input Components
26
+
27
+ HTML input components can be controlled or uncontrolled.
28
+ In a controlled component, the value is controlled by surrounding Javascript code.
29
+ In an uncontrolled component, the value is controlled by the DOM and the surrounding Javascript code only reads the value
30
+ (e.g. by listening to events).
31
+
32
+ All our input components can be used in both ways.
33
+ Every component fires one or two events.
34
+ If the event details can be used to update the LAPIS filter
35
+ _and_ the value then the component will only fire one event,
36
+ otherwise it will fire one event to update the LAPIS filter and one to update the value of the component.
37
+ Refer to the documentation of the individual components for details on which event you can use for which purpose.
38
+
39
+ **Example**: A controlled input component in a React app could conceptually look like this:
40
+
41
+ ```javascript
42
+ import { useEffect, useRef, useState } from 'react';
43
+
44
+ const ExampleInput = () => {
45
+ const [value, setValue] = useState('foo');
46
+ const inputRef = useRef(null);
47
+
48
+ useEffect(() => {
49
+ if (!inputRef.current) {
50
+ return;
51
+ }
52
+
53
+ const eventListener = (event) => {
54
+ setValue(event.detail);
55
+ };
56
+
57
+ inputRef.current.addEventListener('gs-input', eventListener);
58
+
59
+ return () => {
60
+ inputRef.current.removeEventListener('gs-input', eventListener);
61
+ };
62
+ }, []);
63
+
64
+ return <gs-example-input ref={inputRef} value={value} />;
65
+ };
66
+ ```