@genspectrum/dashboard-components 0.11.6 → 0.12.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 (43) hide show
  1. package/custom-elements.json +50 -15
  2. package/dist/{dateRangeOption-Bh2p78z0.js → LocationChangedEvent-CORvQvXv.js} +11 -1
  3. package/dist/LocationChangedEvent-CORvQvXv.js.map +1 -0
  4. package/dist/assets/{mutationOverTimeWorker-CWneD7i5.js.map → mutationOverTimeWorker-DTv93Ere.js.map} +1 -1
  5. package/dist/components.d.ts +79 -51
  6. package/dist/components.js +3951 -621
  7. package/dist/components.js.map +1 -1
  8. package/dist/style.css +151 -4
  9. package/dist/util.d.ts +78 -44
  10. package/dist/util.js +2 -1
  11. package/package.json +2 -1
  12. package/src/preact/components/csv-download-button.tsx +2 -2
  13. package/src/preact/downshift_types.d.ts +3 -0
  14. package/src/preact/locationFilter/LocationChangedEvent.ts +11 -0
  15. package/src/preact/locationFilter/fetchAutocompletionList.spec.ts +5 -5
  16. package/src/preact/locationFilter/fetchAutocompletionList.ts +9 -2
  17. package/src/preact/locationFilter/location-filter.stories.tsx +94 -10
  18. package/src/preact/locationFilter/location-filter.tsx +183 -62
  19. package/src/preact/mutationFilter/mutation-filter-info.tsx +73 -10
  20. package/src/preact/mutations/__mockData__/baselineNucleotideMutations.json +337412 -0
  21. package/src/preact/mutations/__mockData__/overallVariantCount.json +14 -0
  22. package/src/preact/mutations/getMutationsTableData.spec.ts +20 -3
  23. package/src/preact/mutations/getMutationsTableData.ts +37 -2
  24. package/src/preact/mutations/mutations-table.tsx +47 -27
  25. package/src/preact/mutations/mutations.stories.tsx +41 -9
  26. package/src/preact/mutations/mutations.tsx +22 -6
  27. package/src/preact/mutations/queryMutations.ts +28 -8
  28. package/src/preact/mutationsOverTime/__mockData__/aminoAcidMutationsByDay.ts +11077 -3062
  29. package/src/preact/mutationsOverTime/__mockData__/byWeek.ts +3883 -6606
  30. package/src/preact/mutationsOverTime/__mockData__/defaultMockData.ts +17624 -2203
  31. package/src/preact/mutationsOverTime/mutations-over-time.tsx +1 -1
  32. package/src/query/queryMutationsOverTime.spec.ts +144 -4
  33. package/src/query/queryMutationsOverTime.ts +17 -1
  34. package/src/utilEntrypoint.ts +2 -0
  35. package/src/web-components/input/gs-location-filter.stories.ts +34 -29
  36. package/src/web-components/input/gs-location-filter.tsx +6 -13
  37. package/src/web-components/visualization/gs-mutations.stories.ts +62 -4
  38. package/src/web-components/visualization/gs-mutations.tsx +44 -0
  39. package/standalone-bundle/assets/{mutationOverTimeWorker-x1ipPFL0.js.map → mutationOverTimeWorker-DEybsZ5r.js.map} +1 -1
  40. package/standalone-bundle/dashboard-components.js +11021 -8621
  41. package/standalone-bundle/dashboard-components.js.map +1 -1
  42. package/standalone-bundle/style.css +1 -1
  43. package/dist/dateRangeOption-Bh2p78z0.js.map +0 -1
@@ -1,4 +1,6 @@
1
- import { type Meta, type StoryObj } from '@storybook/preact';
1
+ import { type Meta, type PreactRenderer, type StoryObj } from '@storybook/preact';
2
+ import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
3
+ import type { StepFunction } from '@storybook/types';
2
4
 
3
5
  import data from './__mockData__/aggregated.json';
4
6
  import { LocationFilter, type LocationFilterProps } from './location-filter';
@@ -35,7 +37,7 @@ const meta: Meta<LocationFilterProps> = {
35
37
  args: {
36
38
  width: '100%',
37
39
  fields: ['region', 'country', 'division', 'location'],
38
- initialValue: 'Europe',
40
+ value: { region: 'Europe', country: undefined, division: undefined, location: undefined },
39
41
  placeholderText: 'Enter a location',
40
42
  },
41
43
  argTypes: {
@@ -44,9 +46,9 @@ const meta: Meta<LocationFilterProps> = {
44
46
  type: 'object',
45
47
  },
46
48
  },
47
- initialValue: {
49
+ value: {
48
50
  control: {
49
- type: 'text',
51
+ type: 'object',
50
52
  },
51
53
  },
52
54
  width: {
@@ -67,16 +69,98 @@ export default meta;
67
69
  export const Primary: StoryObj<LocationFilterProps> = {
68
70
  render: (args) => (
69
71
  <LapisUrlContext.Provider value={LAPIS_URL}>
70
- <LocationFilter
71
- fields={args.fields}
72
- initialValue={args.initialValue}
73
- width={args.width}
74
- placeholderText={args.placeholderText}
75
- />
72
+ <LocationFilter {...args} />
76
73
  </LapisUrlContext.Provider>
77
74
  ),
75
+ play: async ({ canvasElement, step }) => {
76
+ const { canvas, locationChangedListenerMock } = await prepare(canvasElement, step);
77
+
78
+ step('change location filter value fires event', async () => {
79
+ const input = await inputField(canvas);
80
+ await userEvent.clear(input);
81
+ await userEvent.type(input, 'Germany');
82
+ await userEvent.click(canvas.getByRole('option', { name: 'Germany Europe / Germany' }));
83
+
84
+ await expect(locationChangedListenerMock).toHaveBeenCalledWith(
85
+ expect.objectContaining({
86
+ detail: {
87
+ country: 'Germany',
88
+ region: 'Europe',
89
+ division: undefined,
90
+ location: undefined,
91
+ },
92
+ }),
93
+ );
94
+ });
95
+ },
96
+ };
97
+
98
+ export const ClearSelection: StoryObj<LocationFilterProps> = {
99
+ ...Primary,
100
+ play: async ({ canvasElement, step }) => {
101
+ const { canvas, locationChangedListenerMock } = await prepare(canvasElement, step);
102
+
103
+ step('clear selection fires event with empty filter', async () => {
104
+ const clearSelectionButton = await canvas.findByLabelText('clear selection');
105
+ await userEvent.click(clearSelectionButton);
106
+
107
+ await expect(locationChangedListenerMock).toHaveBeenCalledWith(
108
+ expect.objectContaining({
109
+ detail: {
110
+ country: undefined,
111
+ region: undefined,
112
+ division: undefined,
113
+ location: undefined,
114
+ },
115
+ }),
116
+ );
117
+ });
118
+ },
119
+ };
120
+
121
+ export const OnBlurInput: StoryObj<LocationFilterProps> = {
122
+ ...Primary,
123
+ play: async ({ canvasElement, step }) => {
124
+ const { canvas, locationChangedListenerMock } = await prepare(canvasElement, step);
125
+
126
+ step('after cleared selection by hand and then blur fires event with empty filter', async () => {
127
+ const input = await inputField(canvas);
128
+ await userEvent.clear(input);
129
+ await userEvent.click(canvas.getByLabelText('toggle menu'));
130
+
131
+ await expect(locationChangedListenerMock).toHaveBeenCalledWith(
132
+ expect.objectContaining({
133
+ detail: {
134
+ country: undefined,
135
+ region: undefined,
136
+ division: undefined,
137
+ location: undefined,
138
+ },
139
+ }),
140
+ );
141
+ });
142
+ },
78
143
  };
79
144
 
145
+ const inputField = (canvas: ReturnType<typeof within>) => canvas.findByPlaceholderText('Enter a location');
146
+
147
+ async function prepare(canvasElement: HTMLElement, step: StepFunction<PreactRenderer, unknown>) {
148
+ const canvas = within(canvasElement);
149
+
150
+ const locationChangedListenerMock = fn();
151
+ step('Setup event listener mock', () => {
152
+ canvasElement.addEventListener('gs-location-changed', locationChangedListenerMock);
153
+ });
154
+
155
+ step('location filter is rendered with value', async () => {
156
+ await waitFor(async () => {
157
+ return expect(await inputField(canvas)).toHaveValue('Europe');
158
+ });
159
+ });
160
+
161
+ return { canvas, locationChangedListenerMock };
162
+ }
163
+
80
164
  export const WithNoFields: StoryObj<LocationFilterProps> = {
81
165
  ...Primary,
82
166
  args: {
@@ -1,17 +1,18 @@
1
+ import { useCombobox } from 'downshift/preact';
1
2
  import { type FunctionComponent } from 'preact';
2
- import { useContext, useRef, useState } from 'preact/hooks';
3
- import { type JSXInternal } from 'preact/src/jsx';
3
+ import { useContext, useMemo, useRef, useState } from 'preact/hooks';
4
4
  import z from 'zod';
5
5
 
6
6
  import { fetchAutocompletionList } from './fetchAutocompletionList';
7
7
  import { LapisUrlContext } from '../LapisUrlContext';
8
+ import { type LapisLocationFilter, LocationChangedEvent } from './LocationChangedEvent';
8
9
  import { ErrorBoundary } from '../components/error-boundary';
9
10
  import { LoadingDisplay } from '../components/loading-display';
10
11
  import { ResizeContainer } from '../components/resize-container';
11
12
  import { useQuery } from '../useQuery';
12
13
 
13
14
  const lineageFilterInnerPropsSchema = z.object({
14
- initialValue: z.string().optional(),
15
+ value: z.record(z.string().nullable().optional()).optional(),
15
16
  placeholderText: z.string().optional(),
16
17
  fields: z.array(z.string()).min(1),
17
18
  });
@@ -36,14 +37,9 @@ export const LocationFilter: FunctionComponent<LocationFilterProps> = (props) =>
36
37
  );
37
38
  };
38
39
 
39
- export const LocationFilterInner = ({ initialValue, fields, placeholderText }: LocationFilterInnerProps) => {
40
+ export const LocationFilterInner = ({ value, fields, placeholderText }: LocationFilterInnerProps) => {
40
41
  const lapis = useContext(LapisUrlContext);
41
42
 
42
- const [value, setValue] = useState(initialValue ?? '');
43
- const [unknownLocation, setUnknownLocation] = useState(false);
44
-
45
- const divRef = useRef<HTMLDivElement>(null);
46
-
47
43
  const { data, error, isLoading } = useQuery(() => fetchAutocompletionList(fields, lapis), [fields, lapis]);
48
44
 
49
45
  if (isLoading) {
@@ -53,70 +49,195 @@ export const LocationFilterInner = ({ initialValue, fields, placeholderText }: L
53
49
  throw error;
54
50
  }
55
51
 
56
- const onInput = (event: JSXInternal.TargetedInputEvent<HTMLInputElement>) => {
57
- const inputValue = event.currentTarget.value;
58
- setValue(inputValue);
59
- if (inputValue.trim() === value.trim() && inputValue !== '') {
60
- return;
61
- }
62
- const eventDetail = parseLocation(inputValue, fields);
63
- if (hasAllUndefined(eventDetail) || hasMatchingEntry(data, eventDetail)) {
64
- divRef.current?.dispatchEvent(
65
- new CustomEvent('gs-location-changed', {
66
- detail: eventDetail,
67
- bubbles: true,
68
- composed: true,
69
- }),
70
- );
71
- setUnknownLocation(false);
72
- } else {
73
- setUnknownLocation(true);
52
+ return <LocationSelector fields={fields} value={value} placeholderText={placeholderText} locationData={data} />;
53
+ };
54
+
55
+ type SelectItem = {
56
+ lapisFilter: LapisLocationFilter;
57
+ label: string;
58
+ description: string;
59
+ };
60
+
61
+ const LocationSelector = ({
62
+ fields,
63
+ value,
64
+ placeholderText,
65
+ locationData,
66
+ }: LocationFilterInnerProps & {
67
+ locationData: LapisLocationFilter[];
68
+ }) => {
69
+ const allItems = useMemo(
70
+ () =>
71
+ locationData
72
+ .map((locationFilter) => {
73
+ return toSelectOption(locationFilter, fields);
74
+ })
75
+ .filter((item): item is SelectItem => item !== undefined),
76
+ [locationData, fields],
77
+ );
78
+
79
+ const initialSelectedItem = useMemo(
80
+ () => (value !== undefined ? toSelectOption(value, fields) : null),
81
+ [value, fields],
82
+ );
83
+
84
+ const [items, setItems] = useState(allItems.filter((item) => filterByInputValue(item, initialSelectedItem?.label)));
85
+ const divRef = useRef<HTMLDivElement>(null);
86
+
87
+ const shadowRoot = divRef.current?.shadowRoot ?? undefined;
88
+
89
+ const environment =
90
+ shadowRoot !== undefined
91
+ ? {
92
+ addEventListener: window.addEventListener.bind(window),
93
+ removeEventListener: window.removeEventListener.bind(window),
94
+ document: shadowRoot.ownerDocument,
95
+ Node: window.Node,
96
+ }
97
+ : undefined;
98
+
99
+ const {
100
+ isOpen,
101
+ getToggleButtonProps,
102
+ getMenuProps,
103
+ getInputProps,
104
+ highlightedIndex,
105
+ getItemProps,
106
+ selectedItem,
107
+ inputValue,
108
+ selectItem,
109
+ setInputValue,
110
+ closeMenu,
111
+ } = useCombobox({
112
+ onInputValueChange({ inputValue }) {
113
+ setItems(allItems.filter((item) => filterByInputValue(item, inputValue)));
114
+ },
115
+ onSelectedItemChange({ selectedItem }) {
116
+ if (selectedItem !== null) {
117
+ divRef.current?.dispatchEvent(new LocationChangedEvent(selectedItem.lapisFilter));
118
+ }
119
+ },
120
+ items,
121
+ itemToString(item) {
122
+ return item?.label ?? '';
123
+ },
124
+ initialSelectedItem,
125
+ environment,
126
+ });
127
+
128
+ const onInputBlur = () => {
129
+ if (inputValue === '') {
130
+ divRef.current?.dispatchEvent(new LocationChangedEvent(emptyLocationFilter(fields)));
131
+ selectItem(null);
132
+ } else if (inputValue !== selectedItem?.label) {
133
+ setInputValue(selectedItem?.label || '');
74
134
  }
75
135
  };
76
136
 
137
+ const clearInput = () => {
138
+ divRef.current?.dispatchEvent(new LocationChangedEvent(emptyLocationFilter(fields)));
139
+ selectItem(null);
140
+ };
141
+
142
+ const buttonRef = useRef(null);
143
+
77
144
  return (
78
- <div class='flex w-full' ref={divRef}>
79
- <input
80
- type='text'
81
- class={`input input-bordered grow ${unknownLocation ? 'border-2 border-error' : ''}`}
82
- value={value}
83
- onInput={onInput}
84
- list='countries'
85
- placeholder={placeholderText}
86
- />
87
- <datalist id='countries'>
88
- {data?.map((v) => {
89
- const value = fields
90
- .map((field) => v[field])
91
- .filter((value) => value !== null)
92
- .join(' / ');
93
- return <option key={value} value={value} />;
94
- })}
95
- </datalist>
145
+ <div ref={divRef} className={'relative w-full'}>
146
+ <div className='w-full flex flex-col gap-1'>
147
+ <div
148
+ className='flex gap-0.5 input input-bordered min-w-32'
149
+ onBlur={(event) => {
150
+ if (event.relatedTarget != buttonRef.current) {
151
+ closeMenu();
152
+ }
153
+ }}
154
+ >
155
+ <input
156
+ placeholder={placeholderText}
157
+ className='w-full p-1.5'
158
+ {...getInputProps()}
159
+ onBlur={onInputBlur}
160
+ />
161
+ <button
162
+ aria-label='clear selection'
163
+ className={`px-2 ${inputValue === '' && 'hidden'}`}
164
+ type='button'
165
+ onClick={clearInput}
166
+ tabIndex={-1}
167
+ >
168
+ ×
169
+ </button>
170
+ <button
171
+ aria-label='toggle menu'
172
+ className='px-2'
173
+ type='button'
174
+ {...getToggleButtonProps()}
175
+ ref={buttonRef}
176
+ >
177
+ {isOpen ? <>↑</> : <>↓</>}
178
+ </button>
179
+ </div>
180
+ </div>
181
+ <ul
182
+ className={`absolute bg-white mt-1 shadow-md max-h-80 overflow-scroll z-10 w-full min-w-32 ${
183
+ !(isOpen && items.length > 0) && 'hidden'
184
+ }`}
185
+ {...getMenuProps()}
186
+ >
187
+ {isOpen &&
188
+ items.map((item, index) => (
189
+ <li
190
+ className={`${highlightedIndex === index && 'bg-blue-300'} ${selectedItem !== null && selectedItem.description === item.description && 'font-bold'} py-2 px-3 shadow-sm flex flex-col`}
191
+ key={item.description}
192
+ {...getItemProps({ item, index })}
193
+ >
194
+ <span>{item.label}</span>
195
+ <span className='text-sm text-gray-500'>{item.description}</span>
196
+ </li>
197
+ ))}
198
+ </ul>
96
199
  </div>
97
200
  );
98
201
  };
99
202
 
100
- const parseLocation = (location: string, fields: string[]) => {
101
- if (location === '') {
102
- return fields.reduce((acc, field) => ({ ...acc, [field]: undefined }), {});
203
+ function filterByInputValue(item: SelectItem, inputValue: string | undefined | null) {
204
+ if (inputValue === undefined || inputValue === null) {
205
+ return true;
103
206
  }
104
- const fieldValues = location.split('/').map((part) => part.trim());
207
+ return (
208
+ item?.label.toLowerCase().includes(inputValue.toLowerCase()) ||
209
+ item?.description.toLowerCase().includes(inputValue.toLowerCase())
210
+ );
211
+ }
105
212
 
106
- return fields.reduce((acc, field, i) => ({ ...acc, [field]: fieldValues[i] }), {});
107
- };
213
+ function toSelectOption(locationFilter: LapisLocationFilter, fields: string[]) {
214
+ const concatenatedLocation = concatenateLocation(locationFilter, fields);
108
215
 
109
- const hasAllUndefined = (obj: Record<string, string | undefined>) =>
110
- Object.values(obj).every((value) => value === undefined);
216
+ const lastNonUndefinedField = [...fields]
217
+ .reverse()
218
+ .find((field) => locationFilter[field] !== undefined && locationFilter[field] !== null);
111
219
 
112
- const hasMatchingEntry = (data: Record<string, string>[] | null, eventDetail: Record<string, string>) => {
113
- if (data === null) {
114
- return false;
220
+ if (lastNonUndefinedField === undefined) {
221
+ return undefined;
115
222
  }
116
223
 
117
- const matchingEntries = Object.entries(eventDetail)
118
- .filter(([, value]) => value !== undefined)
119
- .reduce((filteredData, [key, value]) => filteredData.filter((it) => it[key] === value), data);
120
-
121
- return matchingEntries.length > 0;
122
- };
224
+ return {
225
+ lapisFilter: locationFilter,
226
+ label: locationFilter[lastNonUndefinedField],
227
+ description: concatenatedLocation,
228
+ };
229
+ }
230
+
231
+ function concatenateLocation(locationFilter: LapisLocationFilter, fields: string[]) {
232
+ return fields
233
+ .map((field) => locationFilter[field])
234
+ .filter((value) => value !== null && value !== undefined)
235
+ .join(' / ');
236
+ }
237
+
238
+ function emptyLocationFilter(fields: string[]) {
239
+ return fields.reduce((acc, field) => {
240
+ acc[field] = undefined;
241
+ return acc;
242
+ }, {} as LapisLocationFilter);
243
+ }
@@ -1,6 +1,8 @@
1
1
  import { useContext } from 'preact/hooks';
2
+ import { type FC } from 'react';
2
3
 
3
4
  import { isSingleSegmented } from '../../lapisApi/ReferenceGenome';
5
+ import { type SequenceType } from '../../types';
4
6
  import { ReferenceGenomeContext } from '../ReferenceGenomeContext';
5
7
  import Info, { InfoHeadline1, InfoHeadline2, InfoParagraph } from '../components/info';
6
8
 
@@ -13,7 +15,38 @@ export const MutationFilterInfo = () => {
13
15
  <InfoHeadline1> Mutation Filter</InfoHeadline1>
14
16
  <InfoParagraph>This component allows you to filter for mutations at specific positions.</InfoParagraph>
15
17
 
16
- <InfoHeadline2> Nucleotide Mutations and Insertions</InfoHeadline2>
18
+ <InfoHeadline2>Quickstart</InfoHeadline2>
19
+ <InfoParagraph>
20
+ <ul className='list-disc list-inside'>
21
+ <li>
22
+ Filter for nucleotide mutations:{' '}
23
+ <ExampleMutation mutationType='substitution' sequenceType='nucleotide' />
24
+ </li>
25
+ <li>
26
+ Filter for amino acid mutations:{' '}
27
+ <ExampleMutation mutationType='insertion' sequenceType='nucleotide' />
28
+ </li>
29
+ <li>
30
+ Filter for nucleotide insertions:{' '}
31
+ <ExampleMutation mutationType='substitution' sequenceType='amino acid' />
32
+ </li>
33
+ <li>
34
+ Filter for amino acid insertions:{' '}
35
+ <ExampleMutation mutationType='insertion' sequenceType='amino acid' />
36
+ </li>
37
+ </ul>
38
+ </InfoParagraph>
39
+ {!isSingleSegmented(referenceGenome) && (
40
+ <InfoParagraph>
41
+ This organism has the following segments:{' '}
42
+ {referenceGenome.nucleotideSequences.map((gene) => gene.name).join(', ')}.
43
+ </InfoParagraph>
44
+ )}
45
+ <InfoParagraph>
46
+ This organism has the following genes: {referenceGenome.genes.map((gene) => gene.name).join(', ')}.
47
+ </InfoParagraph>
48
+
49
+ <InfoHeadline2>Nucleotide Mutations and Insertions</InfoHeadline2>
17
50
  {isSingleSegmented(referenceGenome) ? (
18
51
  <SingleSegmentedNucleotideMutationsInfo />
19
52
  ) : (
@@ -25,16 +58,13 @@ export const MutationFilterInfo = () => {
25
58
  An amino acid mutation has the format <b>&lt;gene&gt;:&lt;position&gt;&lt;base&gt;</b> or
26
59
  <b>&lt;gene&gt;:&lt;base_ref&gt;&lt;position&gt;&lt;base&gt;</b>. A <b>&lt;base&gt;</b> can be one of
27
60
  the 20 amino acid codes. It can also be <b>-</b> for deletion and <b>X</b> for unknown. Example:{' '}
28
- <b>E:57Q</b>.
61
+ <ExampleMutation mutationType='substitution' sequenceType='amino acid' />.
29
62
  </InfoParagraph>
30
63
  <InfoParagraph>
31
64
  Insertions can be searched for in the same manner, they just need to have <b>ins_</b> appended to the
32
65
  start of the mutation. Example: <b>ins_{firstGene}:31:N</b> would filter for sequences with an insertion
33
66
  of N between positions 31 and 32 in the gene {firstGene}.
34
67
  </InfoParagraph>
35
- <InfoParagraph>
36
- This organism has the following genes: {referenceGenome.genes.map((gene) => gene.name).join(', ')}.
37
- </InfoParagraph>
38
68
 
39
69
  <InfoHeadline2>Insertion Wildcards</InfoHeadline2>
40
70
  <InfoParagraph>
@@ -106,12 +136,45 @@ const MultiSegmentedNucleotideMutationsInfo = () => {
106
136
  </InfoParagraph>
107
137
  <InfoParagraph>
108
138
  Insertions can be searched for in the same manner, they just need to have <b>ins_</b> appended to the
109
- start of the mutation. Example: <b>ins_{firstSegment}:10462:A</b>.
139
+ start of the mutation. Example: <ExampleMutation mutationType='insertion' sequenceType='nucleotide' />.
110
140
  </InfoParagraph>
111
- <InfoParagraph>
112
- This organism has the following segments:{' '}
113
- {referenceGenome.nucleotideSequences.map((gene) => gene.name).join(', ')}.
114
- </InfoParagraph>{' '}
115
141
  </>
116
142
  );
117
143
  };
144
+
145
+ type ExampleMutationProps = {
146
+ sequenceType: SequenceType;
147
+ mutationType: 'substitution' | 'insertion';
148
+ };
149
+
150
+ const ExampleMutation: FC<ExampleMutationProps> = ({ sequenceType, mutationType }) => {
151
+ const referenceGenome = useContext(ReferenceGenomeContext);
152
+
153
+ const firstSegment = referenceGenome.nucleotideSequences[0].name;
154
+ const firstGene = referenceGenome.genes[0].name;
155
+
156
+ if (sequenceType === 'amino acid') {
157
+ switch (mutationType) {
158
+ case 'substitution':
159
+ return <b>{firstGene}:57Q</b>;
160
+ case 'insertion':
161
+ return <b>ins_{firstGene}:31:N</b>;
162
+ }
163
+ }
164
+
165
+ if (isSingleSegmented(referenceGenome)) {
166
+ switch (mutationType) {
167
+ case 'substitution':
168
+ return <b>23T</b>;
169
+ case 'insertion':
170
+ return <b>ins_1046:A</b>;
171
+ }
172
+ }
173
+
174
+ switch (mutationType) {
175
+ case 'substitution':
176
+ return <b>{firstSegment}:23T</b>;
177
+ case 'insertion':
178
+ return <b>ins_{firstSegment}:10462:A</b>;
179
+ }
180
+ };