@genspectrum/dashboard-components 0.19.2 → 0.19.4

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 (61) hide show
  1. package/custom-elements.json +383 -10
  2. package/dist/{LineageFilterChangedEvent-ixHQkq8y.js → LineageFilterChangedEvent-GgkxoF3X.js} +17 -5
  3. package/dist/LineageFilterChangedEvent-GgkxoF3X.js.map +1 -0
  4. package/dist/assets/mutationOverTimeWorker-ChQTFL68.js.map +1 -1
  5. package/dist/components.d.ts +184 -21
  6. package/dist/components.js +9352 -8683
  7. package/dist/components.js.map +1 -1
  8. package/dist/util.d.ts +69 -21
  9. package/dist/util.js +2 -1
  10. package/package.json +1 -1
  11. package/src/componentsEntrypoint.ts +3 -1
  12. package/src/preact/components/error-display.stories.tsx +2 -1
  13. package/src/preact/components/error-display.tsx +2 -3
  14. package/src/preact/components/min-max-range-slider.tsx +19 -4
  15. package/src/preact/components/resize-container.tsx +7 -10
  16. package/src/preact/components/tooltip.tsx +7 -4
  17. package/src/preact/dateRangeFilter/date-range-filter.stories.tsx +9 -5
  18. package/src/preact/dateRangeFilter/date-range-filter.tsx +2 -1
  19. package/src/preact/dateRangeFilter/dateRangeOption.ts +2 -1
  20. package/src/preact/genomeViewer/CDSPlot.tsx +219 -0
  21. package/src/preact/genomeViewer/genome-data-viewer.stories.tsx +113 -0
  22. package/src/preact/genomeViewer/genome-data-viewer.tsx +69 -0
  23. package/src/preact/genomeViewer/loadGff3.spec.ts +61 -0
  24. package/src/preact/genomeViewer/loadGff3.ts +180 -0
  25. package/src/preact/lineageFilter/LineageFilterChangedEvent.ts +3 -1
  26. package/src/preact/lineageFilter/lineage-filter.stories.tsx +3 -2
  27. package/src/preact/locationFilter/LocationChangedEvent.ts +2 -1
  28. package/src/preact/locationFilter/location-filter.stories.tsx +3 -2
  29. package/src/preact/mutationFilter/mutation-filter.stories.tsx +3 -2
  30. package/src/preact/mutationFilter/mutation-filter.tsx +2 -1
  31. package/src/preact/numberRangeFilter/NumberRangeFilterChangedEvent.ts +31 -0
  32. package/src/preact/numberRangeFilter/number-range-filter.stories.tsx +383 -0
  33. package/src/preact/numberRangeFilter/number-range-filter.tsx +159 -0
  34. package/src/preact/numberRangeFilter/useSelectedRangeReducer.ts +137 -0
  35. package/src/preact/shared/charts/colors.ts +1 -1
  36. package/src/preact/textFilter/TextFilterChangedEvent.ts +3 -1
  37. package/src/preact/textFilter/text-filter.stories.tsx +4 -3
  38. package/src/utilEntrypoint.ts +2 -0
  39. package/src/utils/gsEventNames.ts +11 -0
  40. package/src/web-components/input/gs-date-range-filter.stories.ts +4 -3
  41. package/src/web-components/input/gs-date-range-filter.tsx +3 -2
  42. package/src/web-components/input/gs-lineage-filter.stories.ts +3 -2
  43. package/src/web-components/input/gs-lineage-filter.tsx +2 -1
  44. package/src/web-components/input/gs-location-filter.stories.ts +3 -2
  45. package/src/web-components/input/gs-location-filter.tsx +2 -1
  46. package/src/web-components/input/gs-mutation-filter.stories.ts +3 -2
  47. package/src/web-components/input/gs-mutation-filter.tsx +2 -1
  48. package/src/web-components/input/gs-number-range-filter.spec.ts +27 -0
  49. package/src/web-components/input/gs-number-range-filter.stories.ts +96 -0
  50. package/src/web-components/input/gs-number-range-filter.tsx +148 -0
  51. package/src/web-components/input/gs-text-filter.stories.ts +5 -4
  52. package/src/web-components/input/gs-text-filter.tsx +2 -1
  53. package/src/web-components/input/index.ts +1 -0
  54. package/src/web-components/visualization/gs-genome-data-viewer.spec-d.ts +18 -0
  55. package/src/web-components/visualization/gs-genome-data-viewer.stories.ts +108 -0
  56. package/src/web-components/visualization/gs-genome-data-viewer.tsx +59 -0
  57. package/src/web-components/visualization/index.ts +1 -0
  58. package/standalone-bundle/assets/mutationOverTimeWorker-jChgWnwp.js.map +1 -1
  59. package/standalone-bundle/dashboard-components.js +9613 -9059
  60. package/standalone-bundle/dashboard-components.js.map +1 -1
  61. package/dist/LineageFilterChangedEvent-ixHQkq8y.js.map +0 -1
@@ -0,0 +1,219 @@
1
+ import { Fragment, type FunctionComponent } from 'preact';
2
+ import { useMemo, useState } from 'preact/hooks';
3
+
4
+ import { type CDSFeature } from './loadGff3';
5
+ import { MinMaxRangeSlider } from '../components/min-max-range-slider';
6
+ import Tooltip from '../components/tooltip';
7
+ import { singleGraphColorRGBByName } from '../shared/charts/colors';
8
+
9
+ const MAX_TICK_NUMBER = 20;
10
+ const MIN_TICK_NUMBER = 2;
11
+
12
+ function getMaxTickNumber(fullWidth: number): number {
13
+ const baseValue = 1000;
14
+
15
+ const normalizedRatio = fullWidth / baseValue;
16
+
17
+ const ticks = Math.round(MAX_TICK_NUMBER * normalizedRatio);
18
+ return Math.max(MIN_TICK_NUMBER, Math.min(MAX_TICK_NUMBER, ticks));
19
+ }
20
+
21
+ function getTicks(zoomStart: number, zoomEnd: number, fullWidth: number) {
22
+ const maxTickNumber = getMaxTickNumber(fullWidth);
23
+ const length = zoomEnd - zoomStart;
24
+ const minTickSize = length / maxTickNumber;
25
+ let maxTickSize = 10 ** Math.round(Math.log(minTickSize) / Math.log(10));
26
+ const numTicks = Math.round(length / maxTickSize);
27
+ if (numTicks > maxTickNumber) {
28
+ if (numTicks > 2 * maxTickNumber) {
29
+ maxTickSize = maxTickSize * 5;
30
+ } else {
31
+ maxTickSize = maxTickSize * 2;
32
+ }
33
+ }
34
+ const ticks = [];
35
+
36
+ const offset = Math.ceil(zoomStart / maxTickSize);
37
+ ticks.push({ start: zoomStart, end: zoomStart + maxTickSize - (zoomStart % maxTickSize) });
38
+ for (let i = 0; i <= numTicks; i++) {
39
+ const start = i * maxTickSize + offset * maxTickSize;
40
+ if (start >= zoomEnd) {
41
+ break;
42
+ }
43
+ const end = (i + 1) * maxTickSize + offset * maxTickSize;
44
+ if (end > zoomEnd) {
45
+ ticks.push({ start, end: zoomEnd });
46
+ break;
47
+ }
48
+ ticks.push({ start, end });
49
+ }
50
+ return ticks;
51
+ }
52
+
53
+ interface XAxisProps {
54
+ zoomStart: number;
55
+ zoomEnd: number;
56
+ fullWidth: number;
57
+ }
58
+
59
+ const XAxis: FunctionComponent<XAxisProps> = (componentProps) => {
60
+ const { zoomStart, zoomEnd, fullWidth } = componentProps;
61
+
62
+ const ticks = useMemo(() => getTicks(zoomStart, zoomEnd, fullWidth), [zoomStart, zoomEnd, fullWidth]);
63
+ const visibleRegionLength = zoomEnd - zoomStart;
64
+ const averageWidth = visibleRegionLength / ticks.length;
65
+ return (
66
+ <div className={'h-6 relative'}>
67
+ {ticks.map((tick, idx) => {
68
+ const width = tick.end - tick.start;
69
+ const widthPercent = (width / visibleRegionLength) * 100;
70
+ const leftPercent = ((tick.start - zoomStart) / visibleRegionLength) * 100;
71
+ return (
72
+ <div
73
+ key={idx}
74
+ class='absolute text-xs text-black px-1 hover:opacity-80 border-l border-r border-gray-400 border-t'
75
+ style={{
76
+ left: `calc(${leftPercent}% - 1px)`,
77
+ width: `calc(${widthPercent}% - 1px)`,
78
+ }}
79
+ >
80
+ {width >= averageWidth ? tick.start : ''}
81
+ </div>
82
+ );
83
+ })}
84
+ </div>
85
+ );
86
+ };
87
+
88
+ function getTooltipPosition(cds_start: number, cds_end: number, genomeLength: number) {
89
+ const tooltipY = cds_start + (cds_end - cds_start) / 2 < genomeLength / 2 ? 'start' : 'end';
90
+ return `bottom-${tooltipY}` as const;
91
+ }
92
+
93
+ interface TooltipContentProps {
94
+ cds: CDSFeature;
95
+ }
96
+
97
+ const TooltipContent: FunctionComponent<TooltipContentProps> = (componentProps) => {
98
+ const { cds } = componentProps;
99
+ const cdsLength = cds.positions.reduce((acc, pos) => acc + pos.end - pos.start, 0);
100
+ const cdsCoordinates = cds.positions.map((pos) => `${pos.start}-${pos.end}`).join(', ');
101
+ return (
102
+ <>
103
+ <p>
104
+ <span className='font-bold'>{cds.label}</span>
105
+ </p>
106
+ <table>
107
+ <tbody>
108
+ <tr>
109
+ <th className='font-medium px-2 text-right'>CDS Length</th>
110
+ <td>{cdsLength}</td>
111
+ </tr>
112
+ <tr>
113
+ <th className='font-medium px-2 text-right'>CDS Coordinates</th>
114
+ <td>{cdsCoordinates}</td>
115
+ </tr>
116
+ </tbody>
117
+ </table>
118
+ </>
119
+ );
120
+ };
121
+
122
+ interface CDSBarsProps {
123
+ gffData: CDSFeature[][];
124
+ zoomStart: number;
125
+ zoomEnd: number;
126
+ }
127
+
128
+ const CDSBars: FunctionComponent<CDSBarsProps> = (componentProps) => {
129
+ const { gffData, zoomStart, zoomEnd } = componentProps;
130
+ const visibleRegionLength = zoomEnd - zoomStart;
131
+ return (
132
+ <>
133
+ {gffData.map((data, listId) => (
134
+ <div className={'w-full h-6 relative'} key={listId}>
135
+ {data.map((cds, idx) => (
136
+ <Fragment key={idx}>
137
+ {cds.positions.map((position, posIdx) => {
138
+ const start = Math.max(position.start, zoomStart);
139
+ const end = Math.min(position.end, zoomEnd);
140
+
141
+ if (start >= end) {
142
+ return null;
143
+ }
144
+
145
+ const widthPercent = ((end - start) / visibleRegionLength) * 100;
146
+ const leftPercent = ((start - zoomStart) / visibleRegionLength) * 100;
147
+ const tooltipPosition = getTooltipPosition(start, end, visibleRegionLength);
148
+ return (
149
+ <Tooltip
150
+ content={<TooltipContent cds={cds} />}
151
+ position={tooltipPosition}
152
+ key={`${idx}-${posIdx}`}
153
+ tooltipStyle={{ left: `${leftPercent}%` }}
154
+ >
155
+ <div
156
+ className='absolute text-xs text-white rounded px-1 py-0.5 cursor-pointer hover:opacity-80 shadow-md'
157
+ style={{
158
+ left: `${leftPercent}%`,
159
+ width: `${widthPercent}%`,
160
+ backgroundColor: singleGraphColorRGBByName(cds.color),
161
+ whiteSpace: 'nowrap',
162
+ overflow: 'hidden',
163
+ textOverflow: 'ellipsis',
164
+ }}
165
+ >
166
+ {cds.label}
167
+ </div>
168
+ </Tooltip>
169
+ );
170
+ })}
171
+ </Fragment>
172
+ ))}
173
+ </div>
174
+ ))}
175
+ </>
176
+ );
177
+ };
178
+
179
+ interface CDSProps {
180
+ gffData: CDSFeature[][];
181
+ genomeLength: number;
182
+ width: number;
183
+ }
184
+
185
+ const CDSPlot: FunctionComponent<CDSProps> = (componentProps) => {
186
+ const { gffData, genomeLength, width } = componentProps;
187
+
188
+ const [zoomStart, setZoomStart] = useState(0);
189
+ const [zoomEnd, setZoomEnd] = useState(genomeLength);
190
+
191
+ const updateZoomStart = (newStart: number) => {
192
+ setZoomStart(newStart);
193
+ };
194
+
195
+ const updateZoomEnd = (newEnd: number) => {
196
+ setZoomEnd(newEnd);
197
+ };
198
+
199
+ return (
200
+ <div class='p-4'>
201
+ <CDSBars gffData={gffData} zoomStart={zoomStart} zoomEnd={zoomEnd} />
202
+ <XAxis zoomStart={zoomStart} zoomEnd={zoomEnd} fullWidth={width} />
203
+ <div class='relative w-full h-5'>
204
+ <MinMaxRangeSlider
205
+ min={zoomStart}
206
+ max={zoomEnd}
207
+ setMin={updateZoomStart}
208
+ setMax={updateZoomEnd}
209
+ rangeMin={0}
210
+ rangeMax={genomeLength}
211
+ step={1}
212
+ />
213
+ </div>
214
+ <XAxis zoomStart={0} zoomEnd={genomeLength} fullWidth={width} />
215
+ </div>
216
+ );
217
+ };
218
+
219
+ export default CDSPlot;
@@ -0,0 +1,113 @@
1
+ import { type Meta, type StoryObj } from '@storybook/preact';
2
+
3
+ import { GenomeDataViewer, type GenomeDataViewerProps } from './genome-data-viewer';
4
+ import { playThatExpectsErrorMessage } from '../shared/stories/expectErrorMessage';
5
+
6
+ const meta: Meta<GenomeDataViewerProps> = {
7
+ title: 'Visualization/GenomeDataViewer',
8
+ component: GenomeDataViewer,
9
+ argTypes: {
10
+ width: { control: { type: 'text' } },
11
+ },
12
+ };
13
+
14
+ const SimpleData = `
15
+ ##gff-version 3
16
+ #!gff-spec-version 1.21
17
+ #!processor NCBI annotwriter
18
+ ##sequence-region NC_009942.1 1 11029
19
+ ##species https://www.ncbi.nlm.nih.gov/Taxonomy/Browser/wwwtax.cgi?id=11082
20
+ NC_009942.1 RefSeq region 1 11029 . + . ID=NC_009942.1:1..11029;Dbxref=taxon:11082;country=USA;gb-acronym=WNV;gbkey=Src;genome=genomic;isolate=385-99;mol_type=genomic RNA;note=lineage 1%3B Vero cell passage 2 after isolation;strain=NY99
21
+ NC_009942.1 RefSeq gene 97 10395 . + . gene=POLY;ID=gene-WNVNY99_gp1;gbkey=Prot;product=anchored capsid protein ancC;protein_id=YP_005097850.1
22
+ NC_009942.1 RefSeq CDS 97 465 . + . gene=capsid;Parent=gene-WNVNY99_gp1;gbkey=Prot;product=anchored capsid protein ancC;protein_id=YP_005097850.1
23
+ NC_009942.1 RefSeq CDS 466 966 . + . gene=prM;Parent=gene-WNVNY99_gp1;gbkey=Prot;product=protein pr;protein_id=YP_009164953.1
24
+ NC_009942.1 RefSeq CDS 967 2469 . + . gene=env;Parent=gene-WNVNY99_gp1;gbkey=Prot;product=envelope protein E;protein_id=YP_001527880.1
25
+ NC_009942.1 RefSeq CDS 2470 3525 . + . gene=NS1;Parent=gene-WNVNY99_gp1;gbkey=Prot;product=nonstructural protein NS1;protein_id=YP_001527881.1
26
+ NC_009942.1 RefSeq CDS 3526 4218 . + . gene=NS2A;Parent=gene-WNVNY99_gp1;gbkey=Prot;product=nonstructural protein NS2A;protein_id=YP_001527882.1
27
+ NC_009942.1 RefSeq CDS 4219 4611 . + . gene=NS2B;Parent=gene-WNVNY99_gp1;gbkey=Prot;product=nonstructural protein NS2B;protein_id=YP_001527883.1
28
+ NC_009942.1 RefSeq CDS 4612 6468 . + . gene=NS3;Parent=gene-WNVNY99_gp1;gbkey=Prot;product=nonstructural protein NS3;protein_id=YP_001527884.1
29
+ NC_009942.1 RefSeq CDS 6469 6846 . + . gene=NS4A;Parent=gene-WNVNY99_gp1;gbkey=Prot;product=nonstructural protein NS4A;protein_id=YP_001527885.1
30
+ NC_009942.1 RefSeq CDS 6847 6915 . + . gene=2K;Parent=gene-WNVNY99_gp1;gbkey=Prot;product=protein 2K;protein_id=YP_001527885.1
31
+ NC_009942.1 RefSeq CDS 6916 7680 . + . gene=NS4B;Parent=gene-WNVNY99_gp1;gbkey=Prot;product=nonstructural protein NS4B;protein_id=YP_001527886.1
32
+ NC_009942.1 RefSeq CDS 7681 10395 . + . gene=NS5;Parent=gene-WNVNY99_gp1;gbkey=Prot;product=RNA-dependent RNA polymerase NS5;protein_id=YP_001527887.1
33
+ `;
34
+
35
+ const SplicedGeneData = `
36
+ ##gff-version 3
37
+ #!gff-spec-version 1.21
38
+ #!processor NCBI annotwriter
39
+ ##sequence-region NC_026431.1 1 982
40
+ ##species https://www.ncbi.nlm.nih.gov/Taxonomy/Browser/wwwtax.cgi?id=641809
41
+ NC_026431.1 RefSeq region 1 982 . + . ID=NC_026431.1:1..982;Dbxref=taxon:641809;Name=7;collection-date=09-Apr-2009;country=USA: California state;gbkey=Src;genome=genomic;mol_type=viral cRNA;nat-host=Homo sapiens%3B gender M%3B age 54;segment=7;serotype=H1N1;strain=A/California/07/2009
42
+ NC_026431.1 RefSeq CDS 1 26 . + 0 Name=M2;gene=M2;gbkey=CDS;locus_tag=UJ99_s7gp1;protein_id=YP_009118622.1;product=matrix protein 2;ID=cds-YP_009118622.1;Dbxref=GenBank:YP_009118622.1,GeneID:23308108
43
+ NC_026431.1 RefSeq CDS 715 982 . + 1 Name=M2;gene=M2;gbkey=CDS;locus_tag=UJ99_s7gp1;protein_id=YP_009118622.1;product=matrix protein 2;ID=cds-YP_009118622.1;Dbxref=GenBank:YP_009118622.1,GeneID:23308108
44
+ NC_026431.1 RefSeq CDS 1 759 . + 0 Name=M1;gene=M1;gbkey=CDS;locus_tag=UJ99_s7gp2;protein_id=YP_009118623.1;product=matrix protein 1;ID=cds-YP_009118623.1;Dbxref=GenBank:YP_009118623.1,GeneID:23308107
45
+ `;
46
+
47
+ export default meta;
48
+
49
+ const gff3Url = 'http://my.gff.data';
50
+
51
+ export const Default: StoryObj<GenomeDataViewerProps> = {
52
+ render: (args) => <GenomeDataViewer {...args} />,
53
+ args: {
54
+ gff3Source: gff3Url,
55
+ width: '1100px',
56
+ },
57
+ parameters: {
58
+ fetchMock: {
59
+ mocks: [
60
+ {
61
+ matcher: {
62
+ name: 'gff3Data',
63
+ url: gff3Url,
64
+ },
65
+ response: {
66
+ status: 200,
67
+ body: SimpleData,
68
+ headers: {
69
+ 'Content-Type': 'text/plain',
70
+ },
71
+ },
72
+ },
73
+ ],
74
+ },
75
+ },
76
+ };
77
+
78
+ export const InvalidProps: StoryObj<GenomeDataViewerProps> = {
79
+ ...Default,
80
+ args: {
81
+ ...Default.args,
82
+ gff3Source: 'bla',
83
+ },
84
+ play: playThatExpectsErrorMessage('Error - Invalid gff3 source', `Invalid URL passed to parseGFF3: "bla"`),
85
+ };
86
+
87
+ export const SplicedGeneAndOverlap: StoryObj<GenomeDataViewerProps> = {
88
+ render: (args) => <GenomeDataViewer {...args} />,
89
+ args: {
90
+ gff3Source: gff3Url,
91
+ genomeLength: 982,
92
+ width: '1100px',
93
+ },
94
+ parameters: {
95
+ fetchMock: {
96
+ mocks: [
97
+ {
98
+ matcher: {
99
+ name: 'gff3Data',
100
+ url: gff3Url,
101
+ },
102
+ response: {
103
+ status: 200,
104
+ body: SplicedGeneData,
105
+ headers: {
106
+ 'Content-Type': 'text/plain',
107
+ },
108
+ },
109
+ },
110
+ ],
111
+ },
112
+ },
113
+ };
@@ -0,0 +1,69 @@
1
+ import type { FunctionComponent } from 'preact';
2
+ import { useEffect, useRef, useState } from 'preact/hooks';
3
+ import z from 'zod';
4
+
5
+ import { ErrorBoundary } from '../components/error-boundary';
6
+ import { LoadingDisplay } from '../components/loading-display';
7
+ import { ResizeContainer } from '../components/resize-container';
8
+ import { useQuery } from '../useQuery';
9
+ import CDSPlot from './CDSPlot';
10
+ import { loadGff3 } from './loadGff3';
11
+
12
+ const genomeDataViewerPropsSchema = z.object({
13
+ gff3Source: z.string().min(1, 'gff3Source cannot be empty'),
14
+ genomeLength: z.number().gt(0, 'genomeLength must be greater than 0').optional(),
15
+ width: z.string(),
16
+ });
17
+
18
+ interface ExtendedGenomeDataViewerProps extends GenomeDataViewerProps {
19
+ trueWidth: number;
20
+ }
21
+
22
+ export type GenomeDataViewerProps = z.infer<typeof genomeDataViewerPropsSchema>;
23
+
24
+ export const GenomeDataViewer: FunctionComponent<GenomeDataViewerProps> = (componentProps) => {
25
+ const { width } = componentProps;
26
+ const size = { height: '100%', width };
27
+ const containerRef = useRef<HTMLDivElement>(null);
28
+ const [trueWidth, setTrueWidth] = useState(0);
29
+
30
+ useEffect(() => {
31
+ const updateSize = () => {
32
+ if (containerRef.current) {
33
+ setTrueWidth(containerRef.current.getBoundingClientRect().width);
34
+ }
35
+ };
36
+
37
+ updateSize();
38
+ window.addEventListener('resize', updateSize);
39
+ return () => window.removeEventListener('resize', updateSize);
40
+ }, []);
41
+
42
+ return (
43
+ <ErrorBoundary size={size} componentProps={componentProps} schema={genomeDataViewerPropsSchema}>
44
+ <ResizeContainer size={size} ref={containerRef}>
45
+ <GenomeDataViewerInner {...componentProps} trueWidth={trueWidth} />
46
+ </ResizeContainer>
47
+ </ErrorBoundary>
48
+ );
49
+ };
50
+
51
+ const GenomeDataViewerInner: FunctionComponent<ExtendedGenomeDataViewerProps> = (props) => {
52
+ const { gff3Source, genomeLength, trueWidth } = props;
53
+
54
+ const {
55
+ data,
56
+ error,
57
+ isLoading: isLoadingData,
58
+ } = useQuery(() => loadGff3(gff3Source, genomeLength), [gff3Source, genomeLength]);
59
+
60
+ if (isLoadingData) {
61
+ return <LoadingDisplay />;
62
+ }
63
+
64
+ if (error) {
65
+ throw error;
66
+ }
67
+
68
+ return <CDSPlot gffData={data.features} genomeLength={data.length} width={trueWidth} />;
69
+ };
@@ -0,0 +1,61 @@
1
+ import { describe, expect, test } from 'vitest';
2
+
3
+ import { type CDSFeature, parseGFF3, loadGenomeLength } from './loadGff3';
4
+
5
+ const SplicedGeneData = `
6
+ ##gff-version 3
7
+ #!gff-spec-version 1.21
8
+ #!processor NCBI annotwriter
9
+ ##sequence-region NC_026431.1 1 982
10
+ ##species https://www.ncbi.nlm.nih.gov/Taxonomy/Browser/wwwtax.cgi?id=641809
11
+ NC_026431.1 RefSeq region 1 982 . + . ID=NC_026431.1:1..982;Dbxref=taxon:641809;Name=7;collection-date=09-Apr-2009;country=USA: California state;gbkey=Src;genome=genomic;mol_type=viral cRNA;nat-host=Homo sapiens%3B gender M%3B age 54;segment=7;serotype=H1N1;strain=A/California/07/2009
12
+ NC_026431.1 RefSeq CDS 1 26 . + 0 Name=M2;gene=M2;gbkey=CDS;locus_tag=UJ99_s7gp1;protein_id=YP_009118622.1;product=matrix protein 2;ID=cds-YP_009118622.1;Dbxref=GenBank:YP_009118622.1,GeneID:23308108
13
+ NC_026431.1 RefSeq CDS 715 982 . + 1 Name="M2";gene=M2;gbkey=CDS;locus_tag=UJ99_s7gp1;protein_id=YP_009118622.1;product=matrix protein 2;ID=cds-YP_009118622.1;Dbxref=GenBank:YP_009118622.1,GeneID:23308108
14
+ NC_026431.1 RefSeq CDS 1 759 . + 0 Name=M1;gene=M1;gbkey=CDS;locus_tag=UJ99_s7gp2;protein_id=YP_009118623.1;product=matrix protein 1;ID=cds-YP_009118623.1;Dbxref=GenBank:YP_009118623.1,GeneID:23308107
15
+ NC_026431.1 RefSeq CDS 760 790 . + 0 Name=fakeGene;gene=fakeGene;gbkey=CDS;locus_tag=UJ99_s7fake;protein_id=YP_009118624.1;product=None;ID=cds-YP_009118624.1;Dbxref=GenBank:YP_009118624.1,GeneID:23308109
16
+ `;
17
+
18
+ describe('parseGFF3', () => {
19
+ test('should parse GFF3 correctly', () => {
20
+ const result: CDSFeature[][] = parseGFF3(SplicedGeneData);
21
+
22
+ expect(result).to.deep.equal([
23
+ [
24
+ {
25
+ color: 'sand',
26
+ positions: [
27
+ { start: 1, end: 26 },
28
+ { start: 715, end: 982 },
29
+ ],
30
+ label: 'M2',
31
+ },
32
+ ],
33
+ [
34
+ { color: 'rose', positions: [{ start: 1, end: 759 }], label: 'M1' },
35
+ { color: 'wine', positions: [{ start: 760, end: 790 }], label: 'fakeGene' },
36
+ ],
37
+ ]);
38
+ });
39
+ });
40
+
41
+ describe('loadGenomeLength', () => {
42
+ test('should calculate genome length correctly', () => {
43
+ const length = loadGenomeLength(SplicedGeneData);
44
+
45
+ expect(length).to.equal(982);
46
+ });
47
+ });
48
+
49
+ const invalidInput = `
50
+ ##gff-version 3
51
+ #!gff-spec-version 1.21
52
+ #!processor NCBI annotwriter
53
+ ##sequence-region NC_026431.1 1`;
54
+
55
+ describe('loadGenomeLength', () => {
56
+ test('should throw an error when passed invalid input', () => {
57
+ expect(() => loadGenomeLength(invalidInput)).toThrow(
58
+ 'No length found in sequence-region: "##sequence-region NC_026431.1 1"',
59
+ );
60
+ });
61
+ });
@@ -0,0 +1,180 @@
1
+ import { UserFacingError } from '../components/error-display';
2
+ import { ColorsRGB, type GraphColor } from '../shared/charts/colors';
3
+
4
+ export type CDSFeature = {
5
+ positions: Position[];
6
+ label: string;
7
+ color: GraphColor;
8
+ };
9
+
10
+ type Position = {
11
+ start: number;
12
+ end: number;
13
+ };
14
+
15
+ type CDSMap = {
16
+ [id: string]: { positions: Position[]; label: string };
17
+ };
18
+
19
+ export async function loadGff3(gff3Source: string, genomeLength: number | undefined) {
20
+ try {
21
+ new URL(gff3Source);
22
+ } catch (_error) {
23
+ throw new UserFacingError('Invalid gff3 source', `Invalid URL passed to parseGFF3: "${gff3Source}"`);
24
+ }
25
+
26
+ const response = await fetch(gff3Source);
27
+ const content = await response.text();
28
+ if (!genomeLength) {
29
+ genomeLength = loadGenomeLength(content);
30
+ }
31
+ return { features: parseGFF3(content), length: genomeLength };
32
+ }
33
+
34
+ export function parseGFF3(content: string): CDSFeature[][] {
35
+ /**
36
+ * Reads in CDS from GFF3 according to nextclade rules:
37
+ * https://docs.nextstrain.org/projects/nextclade/en/stable/user/input-files/03-genome-annotation.html
38
+ * Read in both gene and CDS features
39
+ * If a CDS feature has a gene feature as a parent, remove the gene feature
40
+ * Split the CDS features into non-overlapping features
41
+ */
42
+ const lines = content.split('\n');
43
+
44
+ const map: CDSMap = {};
45
+
46
+ const geneFeatures = getCDSMap(lines, 'gene', map);
47
+ const cdsFeatures = getCDSMap(lines, 'CDS', geneFeatures);
48
+
49
+ return getNonOverlappingCDSFeatures(getSortedCDSFeatures(cdsFeatures));
50
+ }
51
+
52
+ function getAttributes(attributes: string): Map<string, string> {
53
+ const attrPairs = attributes.split(';');
54
+ const attrMap = new Map<string, string>();
55
+ for (const pair of attrPairs) {
56
+ const pairTrimmed = pair.trim();
57
+ const [key, value] = pairTrimmed.split('=');
58
+ attrMap.set(key, value);
59
+ }
60
+ return attrMap;
61
+ }
62
+
63
+ function removeQuotes(input: string) {
64
+ return input.replace(/^['"](.*)['"]$/, '$1');
65
+ }
66
+
67
+ function getCDSMap(lines: string[], genome_type: string, geneMap: CDSMap): CDSMap {
68
+ for (const line of lines) {
69
+ if (line.startsWith('#') || line.trim() === '') {
70
+ continue;
71
+ }
72
+
73
+ const fields = line.split('\t');
74
+ if (fields.length < 9) {
75
+ throw new UserFacingError('Invalid gff3 source', `Gff3 line has less than 9 fields: "${line}"`);
76
+ }
77
+
78
+ const [, , type, startStr, endStr, , , , attributes] = fields;
79
+
80
+ if (removeQuotes(type.toLowerCase()) !== genome_type.toLowerCase()) {
81
+ continue;
82
+ }
83
+
84
+ const start = parseInt(startStr, 10);
85
+ const end = parseInt(endStr, 10);
86
+
87
+ if (isNaN(start) || isNaN(end)) {
88
+ throw new UserFacingError('Invalid gff3 source', `Invalid start or end position: "${line}"`);
89
+ }
90
+
91
+ const attrPairs = getAttributes(attributes);
92
+ const labelAttribute = attrPairs.get('Name') || attrPairs.get('gene') || attrPairs.get('gene_name');
93
+ if (!labelAttribute) {
94
+ throw new UserFacingError(
95
+ 'Invalid gff3 source',
96
+ `No label found for feature: "${line}", must contain label in Name, gene, or gene_name attribute`,
97
+ );
98
+ }
99
+ const label = removeQuotes(labelAttribute);
100
+ const id = removeQuotes(attrPairs.get('ID') || labelAttribute);
101
+ const parentAttribute = attrPairs.get('Parent');
102
+ if (parentAttribute) {
103
+ const parent = removeQuotes(parentAttribute);
104
+ if (parent && parent in geneMap) {
105
+ delete geneMap[parent];
106
+ }
107
+ }
108
+ if (id in geneMap) {
109
+ geneMap[id].positions.push({ start, end });
110
+ } else {
111
+ geneMap[id] = { positions: [{ start, end }], label };
112
+ }
113
+ }
114
+ return geneMap;
115
+ }
116
+
117
+ function getSortedCDSFeatures(cdsMap: CDSMap): CDSFeature[] {
118
+ const mapValues = Object.values(cdsMap);
119
+
120
+ mapValues.forEach((feature) => {
121
+ feature.positions.sort((a, b) => a.start - b.start);
122
+ });
123
+
124
+ mapValues.sort((a, b) => {
125
+ return a.positions[0].start - b.positions[0].start;
126
+ });
127
+
128
+ const GraphColorList = Object.keys(ColorsRGB) as GraphColor[];
129
+ const colorIndex = mapValues[0].label[0].toUpperCase().charCodeAt(0);
130
+
131
+ const cdsFeatures: CDSFeature[] = mapValues.map((value, index) => ({
132
+ positions: value.positions,
133
+ label: value.label,
134
+ color: GraphColorList[(colorIndex + index) % GraphColorList.length],
135
+ }));
136
+
137
+ return cdsFeatures;
138
+ }
139
+
140
+ function getNonOverlappingCDSFeatures(cdsFeatures: CDSFeature[]): CDSFeature[][] {
141
+ const nonOverlappingCDSFeatures: CDSFeature[][] = [];
142
+ for (const cdsFeature of cdsFeatures) {
143
+ let added = false;
144
+ for (const cdsList of nonOverlappingCDSFeatures) {
145
+ const lastCds = cdsList[cdsList.length - 1];
146
+ if (cdsFeature.positions[0].start > lastCds.positions[lastCds.positions.length - 1].end) {
147
+ cdsList.push(cdsFeature);
148
+ added = true;
149
+ break;
150
+ }
151
+ }
152
+ if (!added) {
153
+ nonOverlappingCDSFeatures.push([cdsFeature]);
154
+ }
155
+ }
156
+ return nonOverlappingCDSFeatures;
157
+ }
158
+
159
+ export function loadGenomeLength(content: string) {
160
+ const lines = content.split('\n');
161
+ for (const line of lines) {
162
+ if (!line.startsWith('#')) {
163
+ continue;
164
+ }
165
+ const fields = line.split(' ');
166
+ if (fields[0] === '##sequence-region') {
167
+ if (fields.length < 3) {
168
+ throw new UserFacingError('Invalid gff3 source', `No length found in sequence-region: "${line}"`);
169
+ }
170
+ const start = fields.at(-2);
171
+ const end = fields.at(-1);
172
+ const length = parseInt(end!, 10) - parseInt(start!, 10) + 1;
173
+ if (isNaN(length)) {
174
+ throw new UserFacingError('Invalid gff3 source', `No length found in sequence-region: "${line}"`);
175
+ }
176
+ return length;
177
+ }
178
+ }
179
+ throw new UserFacingError('Invalid gff3 source', `No length found in sequence-region`);
180
+ }
@@ -1,8 +1,10 @@
1
+ import { gsEventNames } from '../../utils/gsEventNames';
2
+
1
3
  type LapisLineageFilter = Record<string, string | undefined>;
2
4
 
3
5
  export class LineageFilterChangedEvent extends CustomEvent<LapisLineageFilter> {
4
6
  constructor(detail: LapisLineageFilter) {
5
- super('gs-lineage-filter-changed', {
7
+ super(gsEventNames.lineageFilterChanged, {
6
8
  detail,
7
9
  bubbles: true,
8
10
  composed: true,
@@ -6,6 +6,7 @@ import { LineageFilter, type LineageFilterProps } from './lineage-filter';
6
6
  import { previewHandles } from '../../../.storybook/preview';
7
7
  import { AGGREGATED_ENDPOINT, LAPIS_URL } from '../../constants';
8
8
  import aggregatedData from '../../preact/lineageFilter/__mockData__/aggregated.json';
9
+ import { gsEventNames } from '../../utils/gsEventNames';
9
10
  import { LapisUrlContextProvider } from '../LapisUrlContext';
10
11
  import { expectInvalidAttributesErrorMessage } from '../shared/stories/expectErrorMessage';
11
12
 
@@ -14,7 +15,7 @@ const meta: Meta = {
14
15
  component: LineageFilter,
15
16
  parameters: {
16
17
  actions: {
17
- handles: ['gs-lineage-filter-changed', ...previewHandles],
18
+ handles: [gsEventNames.lineageFilterChanged, ...previewHandles],
18
19
  },
19
20
  fetchMock: {
20
21
  mocks: [
@@ -155,7 +156,7 @@ async function prepare(canvasElement: HTMLElement, step: StepFunction<PreactRend
155
156
 
156
157
  const lineageChangedListenerMock = fn();
157
158
  await step('Setup event listener mock', () => {
158
- canvasElement.addEventListener('gs-lineage-filter-changed', lineageChangedListenerMock);
159
+ canvasElement.addEventListener(gsEventNames.lineageFilterChanged, lineageChangedListenerMock);
159
160
  });
160
161
 
161
162
  await step('location filter is rendered with value', async () => {