@genspectrum/dashboard-components 0.18.5 → 0.18.6

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 (33) hide show
  1. package/README.md +12 -0
  2. package/custom-elements.json +1 -1
  3. package/dist/components.d.ts +16 -16
  4. package/dist/components.js +765 -315
  5. package/dist/components.js.map +1 -1
  6. package/dist/style.css +2 -2
  7. package/dist/util.d.ts +16 -16
  8. package/package.json +2 -2
  9. package/src/preact/MutationAnnotationsContext.tsx +34 -27
  10. package/src/preact/components/dropdown.tsx +1 -1
  11. package/src/preact/components/info.tsx +1 -1
  12. package/src/preact/components/mutations-over-time-text-filter.stories.tsx +57 -0
  13. package/src/preact/components/mutations-over-time-text-filter.tsx +63 -0
  14. package/src/preact/components/segment-selector.tsx +1 -1
  15. package/src/preact/mutationFilter/mutation-filter.stories.tsx +169 -50
  16. package/src/preact/mutationFilter/mutation-filter.tsx +239 -234
  17. package/src/preact/mutationFilter/parseAndValidateMutation.ts +62 -10
  18. package/src/preact/mutationFilter/parseMutation.spec.ts +62 -47
  19. package/src/preact/mutationsOverTime/getFilteredMutationsOverTime.spec.ts +128 -0
  20. package/src/preact/mutationsOverTime/getFilteredMutationsOverTimeData.ts +39 -2
  21. package/src/preact/mutationsOverTime/mutations-over-time-grid.tsx +8 -11
  22. package/src/preact/mutationsOverTime/mutations-over-time.stories.tsx +27 -0
  23. package/src/preact/mutationsOverTime/mutations-over-time.tsx +26 -5
  24. package/src/preact/shared/tanstackTable/pagination-context.tsx +30 -0
  25. package/src/preact/shared/tanstackTable/pagination.tsx +19 -6
  26. package/src/preact/shared/tanstackTable/tanstackTable.tsx +17 -3
  27. package/src/preact/wastewater/mutationsOverTime/wastewater-mutations-over-time.stories.tsx +19 -1
  28. package/src/preact/wastewater/mutationsOverTime/wastewater-mutations-over-time.tsx +6 -1
  29. package/src/web-components/input/gs-mutation-filter.stories.ts +4 -4
  30. package/src/web-components/visualization/gs-prevalence-over-time.stories.ts +1 -1
  31. package/standalone-bundle/dashboard-components.js +12896 -13366
  32. package/standalone-bundle/dashboard-components.js.map +1 -1
  33. package/standalone-bundle/style.css +1 -1
@@ -1,10 +1,11 @@
1
+ import { useCombobox, useMultipleSelection } from 'downshift/preact';
1
2
  import { type FunctionComponent } from 'preact';
2
- import { useContext, useRef, useState } from 'preact/hooks';
3
+ import { useContext, useMemo, useRef, useState } from 'preact/hooks';
3
4
  import z from 'zod';
4
5
 
5
6
  import { getExampleMutation } from './ExampleMutation';
6
7
  import { MutationFilterInfo } from './mutation-filter-info';
7
- import { parseAndValidateMutation, type ParsedMutationFilter } from './parseAndValidateMutation';
8
+ import { parseAndValidateMutation } from './parseAndValidateMutation';
8
9
  import { type ReferenceGenome } from '../../lapisApi/ReferenceGenome';
9
10
  import { type MutationsFilter, mutationsFilterSchema } from '../../types';
10
11
  import { type DeletionClass, type InsertionClass, type SubstitutionClass } from '../../utils/mutations';
@@ -24,55 +25,64 @@ const mutationFilterPropsSchema = mutationFilterInnerPropsSchema.extend({
24
25
  export type MutationFilterInnerProps = z.infer<typeof mutationFilterInnerPropsSchema>;
25
26
  export type MutationFilterProps = z.infer<typeof mutationFilterPropsSchema>;
26
27
 
27
- export type SelectedFilters = {
28
- nucleotideMutations: (SubstitutionClass | DeletionClass)[];
29
- aminoAcidMutations: (SubstitutionClass | DeletionClass)[];
30
- nucleotideInsertions: InsertionClass[];
31
- aminoAcidInsertions: InsertionClass[];
28
+ type SelectedNucleotideMutation = {
29
+ type: 'nucleotideMutations';
30
+ value: SubstitutionClass | DeletionClass;
32
31
  };
33
32
 
33
+ type SelectedAminoAcidMutation = {
34
+ type: 'aminoAcidMutations';
35
+ value: SubstitutionClass | DeletionClass;
36
+ };
37
+
38
+ type SelectedNucleotideInsertion = {
39
+ type: 'nucleotideInsertions';
40
+ value: InsertionClass;
41
+ };
42
+
43
+ type SelectedAminoAcidInsertion = {
44
+ type: 'aminoAcidInsertions';
45
+ value: InsertionClass;
46
+ };
47
+
48
+ export type MutationFilterItem =
49
+ | SelectedNucleotideMutation
50
+ | SelectedAminoAcidMutation
51
+ | SelectedNucleotideInsertion
52
+ | SelectedAminoAcidInsertion;
53
+
34
54
  export const MutationFilter: FunctionComponent<MutationFilterProps> = (props) => {
35
55
  const { width, initialValue } = props;
36
56
  return (
37
57
  <ErrorBoundary
38
- size={{ height: '3.375rem', width }}
58
+ size={{ height: '40px', width }}
39
59
  layout='horizontal'
40
60
  schema={mutationFilterPropsSchema}
41
61
  componentProps={props}
42
62
  >
43
- <div style={width}>
63
+ <div style={{ width }}>
44
64
  <MutationFilterInner initialValue={initialValue} />
45
65
  </div>
46
66
  </ErrorBoundary>
47
67
  );
48
68
  };
49
69
 
50
- export const MutationFilterInner: FunctionComponent<MutationFilterInnerProps> = ({ initialValue }) => {
70
+ function MutationFilterInner({ initialValue }: MutationFilterInnerProps) {
51
71
  const referenceGenome = useContext(ReferenceGenomeContext);
52
- const [selectedFilters, setSelectedFilters] = useState<SelectedFilters>(
53
- getInitialState(initialValue, referenceGenome),
54
- );
55
-
56
72
  const filterRef = useRef<HTMLDivElement>(null);
73
+ const [inputValue, setInputValue] = useState('');
57
74
 
58
- if (referenceGenome.nucleotideSequences.length === 0 && referenceGenome.genes.length === 0) {
59
- throw new UserFacingError(
60
- 'No reference sequences available',
61
- 'This organism has neither nucleotide nor amino acid sequences configured in its reference genome. You cannot filter by mutations.',
62
- );
63
- }
75
+ const initialState = useMemo(() => {
76
+ return getInitialState(initialValue, referenceGenome);
77
+ }, [initialValue, referenceGenome]);
64
78
 
65
- const handleRemoveValue = (option: ParsedMutationFilter) => {
66
- const newSelectedFilters = {
67
- ...selectedFilters,
68
- [option.type]: selectedFilters[option.type].filter((i) => option.value.toString() != i.toString()),
69
- };
79
+ const [selectedItems, setSelectedItems] = useState<MutationFilterItem[]>(initialState);
80
+ const [itemCandidate, setItemCandidate] = useState<MutationFilterItem | null>(null);
81
+ const [showErrorIndicator, setShowErrorIndicator] = useState(false);
70
82
 
71
- setSelectedFilters(newSelectedFilters);
72
- fireChangeEvent(newSelectedFilters);
73
- };
83
+ const items = itemCandidate ? [itemCandidate] : [];
74
84
 
75
- const fireChangeEvent = (selectedFilters: SelectedFilters) => {
85
+ const fireChangeEvent = (selectedFilters: MutationFilterItem[]) => {
76
86
  const detail = mapToMutationFilterStrings(selectedFilters);
77
87
 
78
88
  filterRef.current?.dispatchEvent(
@@ -84,170 +94,183 @@ export const MutationFilterInner: FunctionComponent<MutationFilterInnerProps> =
84
94
  );
85
95
  };
86
96
 
87
- return (
88
- <div className='w-full border border-gray-300 rounded-md relative' ref={filterRef}>
89
- <div className='absolute -top-3 -right-3 z-10'>
90
- <MutationFilterInfo />
91
- </div>
92
-
93
- <div className='relative w-full p-1'>
94
- <MutationFilterSelector
95
- referenceGenome={referenceGenome}
96
- setSelectedFilters={(newSelectedFilters) => {
97
- setSelectedFilters(newSelectedFilters);
98
- fireChangeEvent(newSelectedFilters);
99
- }}
100
- selectedFilters={selectedFilters}
101
- />
102
- <SelectedMutationFilterDisplay
103
- selectedFilters={selectedFilters}
104
- handleRemoveValue={handleRemoveValue}
105
- />
106
- </div>
107
- </div>
108
- );
109
- };
110
-
111
- function getInitialState(initialValue: MutationsFilter | string[] | undefined, referenceGenome: ReferenceGenome) {
112
- if (initialValue === undefined) {
113
- return {
114
- nucleotideMutations: [],
115
- aminoAcidMutations: [],
116
- nucleotideInsertions: [],
117
- aminoAcidInsertions: [],
118
- };
119
- }
120
-
121
- const values = Array.isArray(initialValue) ? initialValue : Object.values(initialValue).flatMap((it) => it);
122
-
123
- return values.reduce(
124
- (selectedFilters, value) => {
125
- const parsedMutation = parseAndValidateMutation(value, referenceGenome);
126
- if (parsedMutation === null) {
127
- return selectedFilters;
128
- }
129
-
130
- return {
131
- ...selectedFilters,
132
- [parsedMutation.type]: [...selectedFilters[parsedMutation.type], parsedMutation.value],
133
- };
134
- },
135
- {
136
- nucleotideMutations: [],
137
- aminoAcidMutations: [],
138
- nucleotideInsertions: [],
139
- aminoAcidInsertions: [],
140
- } as SelectedFilters,
141
- );
142
- }
143
-
144
- const MutationFilterSelector: FunctionComponent<{
145
- referenceGenome: ReferenceGenome;
146
- setSelectedFilters: (option: SelectedFilters) => void;
147
- selectedFilters: SelectedFilters;
148
- }> = ({ referenceGenome, setSelectedFilters, selectedFilters }) => {
149
- const [option, setOption] = useState<ParsedMutationFilter | null>(null);
150
- const [inputValue, setInputValue] = useState('');
151
-
152
- const selectorRef = useRef<HTMLDivElement>(null);
97
+ const handleSelectedItemsChanged = (newSelectedItems: MutationFilterItem[]) => {
98
+ fireChangeEvent(newSelectedItems);
99
+ setSelectedItems(newSelectedItems);
100
+ };
153
101
 
154
- const handleInputChange = (newValue: string) => {
155
- if (newValue.includes(',') || newValue.includes(';')) {
156
- handleCommaSeparatedInput(newValue);
157
- } else {
158
- setInputValue(newValue);
159
- const result = parseAndValidateMutation(newValue, referenceGenome);
160
- setOption(result);
102
+ const handleNewSelectedItem = (selectedItem: MutationFilterItem | null | undefined) => {
103
+ if (selectedItem) {
104
+ handleSelectedItemsChanged([...selectedItems, selectedItem]);
105
+ setInputValue('');
106
+ setItemCandidate(null);
107
+ setShowErrorIndicator(false);
161
108
  }
162
109
  };
163
110
 
164
- const handleCommaSeparatedInput = (inputValue: string) => {
165
- const inputValues = inputValue.split(/[,;]/);
166
- let newSelectedOptions = selectedFilters;
167
- let updated: boolean = false;
168
- const invalidQueries: string[] = [];
169
- for (const value of inputValues) {
170
- const trimmedValue = value.trim();
171
- const parsedMutation = parseAndValidateMutation(trimmedValue, referenceGenome);
172
- if (parsedMutation) {
173
- const type = parsedMutation.type;
174
- if (!selectedFilters[type].some((i) => parsedMutation.value.toString() === i.toString())) {
175
- newSelectedOptions = {
176
- ...newSelectedOptions,
177
- [parsedMutation.type]: [...newSelectedOptions[parsedMutation.type], parsedMutation.value],
178
- };
179
- updated = true;
111
+ const handleInputChange = (newInputValue: string | undefined) => {
112
+ setShowErrorIndicator(false);
113
+ if (newInputValue?.includes(',')) {
114
+ const values = newInputValue?.split(',').map((value) => {
115
+ return { value, parsedValue: parseAndValidateMutation(value.trim(), referenceGenome) };
116
+ });
117
+ const validEntries = values.map((value) => value.parsedValue).filter((value) => value !== null);
118
+ const invalidInput = values
119
+ .filter((value) => value.parsedValue === null)
120
+ .map((value) => value.value.trim())
121
+ .join(',');
122
+
123
+ const selectedItemCandidates = [...selectedItems, ...validEntries];
124
+
125
+ handleSelectedItemsChanged(extractUniqueValues(selectedItemCandidates));
126
+ setInputValue(invalidInput);
127
+ setItemCandidate(null);
128
+ } else {
129
+ setInputValue(newInputValue ?? '');
130
+ if (newInputValue !== undefined) {
131
+ const candidate = parseAndValidateMutation(newInputValue, referenceGenome);
132
+ const alreadyExists = selectedItems.find(
133
+ (selectedItem) => selectedItem.value.code === candidate?.value.code,
134
+ );
135
+ if (!alreadyExists) {
136
+ setItemCandidate(candidate);
180
137
  }
181
- } else {
182
- invalidQueries.push(trimmedValue);
183
138
  }
184
139
  }
140
+ };
185
141
 
186
- setInputValue(invalidQueries.join(','));
142
+ const shadowRoot = filterRef.current?.shadowRoot ?? undefined;
143
+
144
+ const environment =
145
+ shadowRoot !== undefined
146
+ ? {
147
+ addEventListener: window.addEventListener.bind(window),
148
+ removeEventListener: window.removeEventListener.bind(window),
149
+ document: shadowRoot.ownerDocument,
150
+ Node: window.Node,
151
+ }
152
+ : undefined;
153
+
154
+ const { getDropdownProps, removeSelectedItem } = useMultipleSelection({
155
+ selectedItems,
156
+ onStateChange({ selectedItems: newSelectedItems, type }) {
157
+ switch (type) {
158
+ case useMultipleSelection.stateChangeTypes.FunctionRemoveSelectedItem:
159
+ handleSelectedItemsChanged(newSelectedItems ?? []);
160
+ break;
161
+ default:
162
+ break;
163
+ }
164
+ },
165
+ environment,
166
+ });
187
167
 
188
- if (updated) {
189
- setSelectedFilters(newSelectedOptions);
190
- setOption(null);
191
- }
192
- };
168
+ const { isOpen, getMenuProps, getInputProps, highlightedIndex, getItemProps, selectedItem } = useCombobox({
169
+ items,
170
+ itemToString(item: MutationFilterItem | undefined | null) {
171
+ return item ? item.value.code : '';
172
+ },
173
+ defaultHighlightedIndex: 0,
174
+ inputValue,
175
+ onStateChange({ inputValue: newInputValue, type, selectedItem: newSelectedItem }) {
176
+ switch (type) {
177
+ case useCombobox.stateChangeTypes.InputKeyDownEnter:
178
+ case useCombobox.stateChangeTypes.ItemClick:
179
+ case useCombobox.stateChangeTypes.InputBlur:
180
+ handleNewSelectedItem(newSelectedItem);
181
+ break;
182
+
183
+ case useCombobox.stateChangeTypes.InputChange: {
184
+ handleInputChange(newInputValue);
185
+ break;
186
+ }
187
+ default:
188
+ break;
189
+ }
190
+ },
191
+ environment,
192
+ });
193
193
 
194
- const handleOptionClick = () => {
195
- if (option === null) {
196
- return;
197
- }
194
+ if (referenceGenome.nucleotideSequences.length === 0 && referenceGenome.genes.length === 0) {
195
+ throw new UserFacingError(
196
+ 'No reference sequences available',
197
+ 'This organism has neither nucleotide nor amino acid sequences configured in its reference genome. You cannot filter by mutations.',
198
+ );
199
+ }
198
200
 
199
- const type = option.type;
201
+ return (
202
+ <div className='w-full' ref={filterRef}>
203
+ <div className={`flex gap-x-1 flex-wrap p-1 input h-fit w-full ${showErrorIndicator ? 'input-error' : ''}`}>
204
+ {selectedItems.map((selectedItemForRender, index) => {
205
+ return (
206
+ <div className='my-1' key={`selected-item-${index}`}>
207
+ <SelectedFilter
208
+ handleRemoveValue={() => {
209
+ removeSelectedItem(selectedItemForRender);
210
+ }}
211
+ mutationFilter={selectedItemForRender}
212
+ />
213
+ </div>
214
+ );
215
+ })}
216
+ <div className='flex gap-0.5 grow p-1'>
217
+ <input
218
+ placeholder={getPlaceholder(referenceGenome)}
219
+ className='w-full focus:outline-none min-w-8'
220
+ {...getInputProps(getDropdownProps({ preventKeyAction: isOpen }))}
221
+ onBlur={() => {
222
+ setShowErrorIndicator(inputValue !== '');
223
+ }}
224
+ size={10}
225
+ />
226
+ <MutationFilterInfo />
227
+ </div>
228
+ </div>
229
+ <ul
230
+ className={`absolute w-inherit bg-white mt-1 shadow-md max-h-80 overflow-scroll p-0 z-10 ${
231
+ !isOpen && 'hidden'
232
+ }`}
233
+ {...getMenuProps()}
234
+ >
235
+ {items.map((item, index) => (
236
+ <li
237
+ className={`${highlightedIndex === index && 'bg-blue-300'} ${selectedItem === item && 'font-bold'} py-2 px-3 shadow-sm flex flex-col cursor-pointer`}
238
+ key={`${item.value.code}${index}`}
239
+ {...getItemProps({ item, index })}
240
+ style={{
241
+ backgroundColor: backgroundColorMap(item, highlightedIndex === index ? 0.4 : 0.2),
242
+ }}
243
+ >
244
+ <span>{item.value.code}</span>
245
+ </li>
246
+ ))}
247
+ </ul>
248
+ </div>
249
+ );
250
+ }
200
251
 
201
- if (!selectedFilters[type].some((i) => option.value.toString() === i.toString())) {
202
- const newSelectedValues = {
203
- ...selectedFilters,
204
- [option?.type]: [...selectedFilters[option?.type], option?.value],
205
- };
206
- setSelectedFilters(newSelectedValues);
252
+ function extractUniqueValues(newSelectedItems: MutationFilterItem[]) {
253
+ const uniqueMutationsMap = new Map<string, MutationFilterItem>();
254
+ for (const mutation of newSelectedItems) {
255
+ if (!uniqueMutationsMap.has(mutation.value.code)) {
256
+ uniqueMutationsMap.set(mutation.value.code, mutation);
207
257
  }
258
+ }
208
259
 
209
- setInputValue('');
210
- setOption(null);
211
- };
260
+ return Array.from(uniqueMutationsMap.values());
261
+ }
212
262
 
213
- const handleEnterPress = (event: KeyboardEvent) => {
214
- if (event.key === 'Enter' && option !== null) {
215
- handleOptionClick();
216
- }
217
- };
263
+ function getInitialState(initialValue: MutationsFilter | string[] | undefined, referenceGenome: ReferenceGenome) {
264
+ if (initialValue === undefined) {
265
+ return [];
266
+ }
218
267
 
219
- const handleBlur = (event: FocusEvent) => {
220
- if (!selectorRef.current?.contains(event.relatedTarget as Node)) {
221
- setOption(null);
222
- }
223
- };
268
+ const values = Array.isArray(initialValue) ? initialValue : Object.values(initialValue).flatMap((it) => it);
224
269
 
225
- return (
226
- <div ref={selectorRef} tabIndex={-1}>
227
- <input
228
- type='text'
229
- className='w-full p-2 border-gray-300 border rounded-md'
230
- placeholder={getPlaceholder(referenceGenome)}
231
- value={inputValue}
232
- onInput={(e: Event) => {
233
- handleInputChange((e.target as HTMLInputElement).value);
234
- }}
235
- onKeyDown={(e) => handleEnterPress(e)}
236
- onFocus={() => handleInputChange(inputValue)}
237
- onBlur={handleBlur}
238
- />
239
- {option != null && (
240
- <div
241
- role='option'
242
- className='hover:bg-gray-300 absolute cursor-pointer p-2 border-1 border-slate-500 bg-slate-200'
243
- onClick={() => handleOptionClick()}
244
- >
245
- {option.value.toString()}
246
- </div>
247
- )}
248
- </div>
249
- );
250
- };
270
+ return values
271
+ .map((value) => parseAndValidateMutation(value, referenceGenome))
272
+ .filter((parsedMutation) => parsedMutation !== null);
273
+ }
251
274
 
252
275
  function getPlaceholder(referenceGenome: ReferenceGenome) {
253
276
  const nucleotideSubstitution = getExampleMutation(referenceGenome, 'nucleotide', 'substitution');
@@ -262,82 +285,64 @@ function getPlaceholder(referenceGenome: ReferenceGenome) {
262
285
  return `Enter a mutation (e.g. ${exampleMutations})`;
263
286
  }
264
287
 
265
- const backgroundColor: { [key in keyof SelectedFilters]: string } = {
266
- aminoAcidMutations: singleGraphColorRGBByName('teal', 0.4),
267
- nucleotideMutations: singleGraphColorRGBByName('green', 0.4),
268
- aminoAcidInsertions: singleGraphColorRGBByName('purple', 0.4),
269
- nucleotideInsertions: singleGraphColorRGBByName('indigo', 0.4),
270
- };
271
-
272
- const backgroundColorMap = (data: ParsedMutationFilter) => {
273
- return backgroundColor[data.type] || 'lightgray';
274
- };
275
-
276
- const SelectedMutationFilterDisplay: FunctionComponent<{
277
- selectedFilters: SelectedFilters;
278
- handleRemoveValue: (option: ParsedMutationFilter) => void;
279
- }> = ({ selectedFilters, handleRemoveValue }) => {
280
- return (
281
- <div className='flex flex-wrap'>
282
- {selectedFilters.nucleotideMutations.map((mutation) => (
283
- <SelectedFilter
284
- key={mutation.toString()}
285
- handleRemoveValue={handleRemoveValue}
286
- mutationFilter={{ type: 'nucleotideMutations', value: mutation }}
287
- />
288
- ))}
289
- {selectedFilters.aminoAcidMutations.map((mutation) => (
290
- <SelectedFilter
291
- key={mutation.toString()}
292
- handleRemoveValue={handleRemoveValue}
293
- mutationFilter={{ type: 'aminoAcidMutations', value: mutation }}
294
- />
295
- ))}
296
- {selectedFilters.nucleotideInsertions.map((mutation) => (
297
- <SelectedFilter
298
- key={mutation.toString()}
299
- handleRemoveValue={handleRemoveValue}
300
- mutationFilter={{ type: 'nucleotideInsertions', value: mutation }}
301
- />
302
- ))}
303
- {selectedFilters.aminoAcidInsertions.map((mutation) => (
304
- <SelectedFilter
305
- key={mutation.toString()}
306
- handleRemoveValue={handleRemoveValue}
307
- mutationFilter={{ type: 'aminoAcidInsertions', value: mutation }}
308
- />
309
- ))}
310
- </div>
311
- );
288
+ const backgroundColorMap = (data: MutationFilterItem, alpha: number = 0.4) => {
289
+ switch (data.type) {
290
+ case 'nucleotideMutations':
291
+ return singleGraphColorRGBByName('green', alpha);
292
+ case 'aminoAcidMutations':
293
+ return singleGraphColorRGBByName('teal', alpha);
294
+ case 'nucleotideInsertions':
295
+ return singleGraphColorRGBByName('indigo', alpha);
296
+ case 'aminoAcidInsertions':
297
+ return singleGraphColorRGBByName('purple', alpha);
298
+ }
312
299
  };
313
300
 
314
301
  type SelectedFilterProps = {
315
- handleRemoveValue: (mutation: ParsedMutationFilter) => void;
316
- mutationFilter: ParsedMutationFilter;
302
+ handleRemoveValue: (mutation: MutationFilterItem) => void;
303
+ mutationFilter: MutationFilterItem;
317
304
  };
318
305
 
319
306
  const SelectedFilter = ({ handleRemoveValue, mutationFilter }: SelectedFilterProps) => {
320
307
  return (
321
308
  <span
322
309
  key={mutationFilter.value.toString()}
323
- className='center p-2 m-1 inline-flex text-black rounded-md'
310
+ className='center px-2 py-1 inline-flex text-black rounded-md'
324
311
  style={{
325
312
  backgroundColor: backgroundColorMap(mutationFilter),
326
313
  }}
327
314
  >
328
315
  {mutationFilter.value.toString()}
329
- <button className='ml-1 cursor-pointer' onClick={() => handleRemoveValue(mutationFilter)}>
316
+ <button
317
+ className='ml-1 cursor-pointer'
318
+ aria-label={`remove mutation filter ${mutationFilter.value.code}`}
319
+ onClick={() => handleRemoveValue(mutationFilter)}
320
+ >
330
321
  ×
331
322
  </button>
332
323
  </span>
333
324
  );
334
325
  };
335
326
 
336
- function mapToMutationFilterStrings(selectedFilters: SelectedFilters) {
337
- return {
338
- aminoAcidMutations: selectedFilters.aminoAcidMutations.map((mutation) => mutation.toString()),
339
- nucleotideMutations: selectedFilters.nucleotideMutations.map((mutation) => mutation.toString()),
340
- aminoAcidInsertions: selectedFilters.aminoAcidInsertions.map((insertion) => insertion.toString()),
341
- nucleotideInsertions: selectedFilters.nucleotideInsertions.map((insertion) => insertion.toString()),
342
- };
327
+ function mapToMutationFilterStrings(selectedFilters: MutationFilterItem[]) {
328
+ return selectedFilters.reduce<MutationsFilter>(
329
+ (acc, filter) => {
330
+ switch (filter.type) {
331
+ case 'nucleotideMutations':
332
+ return { ...acc, nucleotideMutations: [...acc.nucleotideMutations, filter.value.toString()] };
333
+ case 'aminoAcidMutations':
334
+ return { ...acc, aminoAcidMutations: [...acc.aminoAcidMutations, filter.value.toString()] };
335
+ case 'nucleotideInsertions':
336
+ return { ...acc, nucleotideInsertions: [...acc.nucleotideInsertions, filter.value.toString()] };
337
+ case 'aminoAcidInsertions':
338
+ return { ...acc, aminoAcidInsertions: [...acc.aminoAcidInsertions, filter.value.toString()] };
339
+ }
340
+ },
341
+ {
342
+ aminoAcidMutations: [],
343
+ nucleotideMutations: [],
344
+ aminoAcidInsertions: [],
345
+ nucleotideInsertions: [],
346
+ },
347
+ );
343
348
  }
@@ -1,22 +1,44 @@
1
- import { type SelectedFilters } from './mutation-filter';
1
+ import { type MutationFilterItem } from './mutation-filter';
2
2
  import { sequenceTypeFromSegment } from './sequenceTypeFromSegment';
3
3
  import type { ReferenceGenome } from '../../lapisApi/ReferenceGenome';
4
- import { DeletionClass, InsertionClass, SubstitutionClass } from '../../utils/mutations';
5
-
6
- export type ParsedMutationFilter = {
7
- [MutationType in keyof SelectedFilters]: { type: MutationType; value: SelectedFilters[MutationType][number] };
8
- }[keyof SelectedFilters];
4
+ import type { SequenceType } from '../../types';
5
+ import { DeletionClass, InsertionClass, type Mutation, SubstitutionClass } from '../../utils/mutations';
9
6
 
10
7
  export const parseAndValidateMutation = (
11
8
  value: string,
12
9
  referenceGenome: ReferenceGenome,
13
- ): ParsedMutationFilter | null => {
10
+ ): MutationFilterItem | null => {
11
+ const possibleMutation = parseMutation(value, referenceGenome);
12
+ if (possibleMutation === null) {
13
+ return null;
14
+ }
15
+ const sequenceType = getSequenceType(possibleMutation.type);
16
+ const isOutside = isOutsideReferenceGenome(possibleMutation.value, referenceGenome, sequenceType);
17
+ if (isOutside) {
18
+ return null;
19
+ }
20
+ return possibleMutation;
21
+ };
22
+
23
+ const getSequenceType = (type: MutationFilterItem['type']) => {
24
+ switch (type) {
25
+ case 'nucleotideInsertions':
26
+ case 'nucleotideMutations':
27
+ return 'nucleotide';
28
+ case 'aminoAcidInsertions':
29
+ case 'aminoAcidMutations':
30
+ return 'amino acid';
31
+ }
32
+ };
33
+
34
+ const parseMutation = (value: string, referenceGenome: ReferenceGenome): MutationFilterItem | null => {
14
35
  const possibleInsertion = InsertionClass.parse(value);
15
36
  if (possibleInsertion !== null) {
16
37
  const sequenceType = sequenceTypeFromSegment(possibleInsertion.segment, referenceGenome);
17
38
  switch (sequenceType) {
18
- case 'nucleotide':
39
+ case 'nucleotide': {
19
40
  return { type: 'nucleotideInsertions', value: possibleInsertion };
41
+ }
20
42
  case 'amino acid':
21
43
  return { type: 'aminoAcidInsertions', value: possibleInsertion };
22
44
  case undefined:
@@ -41,10 +63,13 @@ export const parseAndValidateMutation = (
41
63
  if (possibleSubstitution !== null) {
42
64
  const sequenceType = sequenceTypeFromSegment(possibleSubstitution.segment, referenceGenome);
43
65
  switch (sequenceType) {
44
- case 'nucleotide':
66
+ case 'nucleotide': {
45
67
  return { type: 'nucleotideMutations', value: possibleSubstitution };
46
- case 'amino acid':
68
+ }
69
+ case 'amino acid': {
47
70
  return { type: 'aminoAcidMutations', value: possibleSubstitution };
71
+ }
72
+
48
73
  case undefined:
49
74
  return null;
50
75
  }
@@ -52,3 +77,30 @@ export const parseAndValidateMutation = (
52
77
 
53
78
  return null;
54
79
  };
80
+
81
+ function isOutsideReferenceGenome(mutation: Mutation, referenceGenome: ReferenceGenome, sequenceType: SequenceType) {
82
+ const lengthOfSegment = getLengthOfSegment(mutation.segment, referenceGenome, sequenceType);
83
+ if (lengthOfSegment === undefined) {
84
+ return true;
85
+ }
86
+
87
+ return mutation.position >= lengthOfSegment;
88
+ }
89
+
90
+ function getLengthOfSegment(segment: string | undefined, referenceGenome: ReferenceGenome, sequenceType: SequenceType) {
91
+ switch (sequenceType) {
92
+ case 'nucleotide': {
93
+ if (referenceGenome.nucleotideSequences.length === 1) {
94
+ return referenceGenome.nucleotideSequences.at(0)?.sequence.length;
95
+ }
96
+
97
+ return referenceGenome.nucleotideSequences.find(
98
+ (sequence) => sequence.name.toUpperCase() === segment?.toUpperCase(),
99
+ )?.sequence.length;
100
+ }
101
+ case 'amino acid': {
102
+ return referenceGenome.genes.find((gene) => gene.name.toUpperCase() === segment?.toUpperCase())?.sequence
103
+ .length;
104
+ }
105
+ }
106
+ }