@genspectrum/dashboard-components 0.19.1 → 0.19.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/custom-elements.json +160 -10
- package/dist/{LineageFilterChangedEvent-ixHQkq8y.js → LineageFilterChangedEvent-b0iuroUL.js} +15 -5
- package/dist/LineageFilterChangedEvent-b0iuroUL.js.map +1 -0
- package/dist/assets/mutationOverTimeWorker-ChQTFL68.js.map +1 -1
- package/dist/components.d.ts +61 -15
- package/dist/components.js +9083 -8727
- package/dist/components.js.map +1 -1
- package/dist/util.d.ts +41 -15
- package/dist/util.js +2 -1
- package/package.json +1 -1
- package/src/componentsEntrypoint.ts +3 -1
- package/src/preact/components/downshift-combobox.tsx +31 -16
- package/src/preact/components/error-display.stories.tsx +2 -1
- package/src/preact/components/error-display.tsx +2 -3
- package/src/preact/components/resize-container.tsx +7 -10
- package/src/preact/components/tooltip.tsx +7 -4
- package/src/preact/dateRangeFilter/date-range-filter.stories.tsx +5 -4
- package/src/preact/dateRangeFilter/date-range-filter.tsx +2 -1
- package/src/preact/dateRangeFilter/dateRangeOption.ts +2 -1
- package/src/preact/genomeViewer/CDSPlot.tsx +219 -0
- package/src/preact/genomeViewer/genome-data-viewer.stories.tsx +113 -0
- package/src/preact/genomeViewer/genome-data-viewer.tsx +69 -0
- package/src/preact/genomeViewer/loadGff3.spec.ts +61 -0
- package/src/preact/genomeViewer/loadGff3.ts +174 -0
- package/src/preact/lineageFilter/LineageFilterChangedEvent.ts +3 -1
- package/src/preact/lineageFilter/lineage-filter.stories.tsx +3 -2
- package/src/preact/lineageFilter/lineage-filter.tsx +4 -4
- package/src/preact/locationFilter/LocationChangedEvent.ts +2 -1
- package/src/preact/locationFilter/location-filter.stories.tsx +3 -2
- package/src/preact/locationFilter/location-filter.tsx +5 -7
- package/src/preact/mutationFilter/mutation-filter.stories.tsx +3 -2
- package/src/preact/mutationFilter/mutation-filter.tsx +2 -1
- package/src/preact/shared/charts/colors.ts +1 -1
- package/src/preact/textFilter/TextFilterChangedEvent.ts +3 -1
- package/src/preact/textFilter/text-filter.stories.tsx +71 -10
- package/src/preact/textFilter/text-filter.tsx +5 -7
- package/src/utilEntrypoint.ts +2 -0
- package/src/utils/gsEventNames.ts +9 -0
- package/src/web-components/input/gs-date-range-filter.stories.ts +4 -3
- package/src/web-components/input/gs-date-range-filter.tsx +3 -2
- package/src/web-components/input/gs-lineage-filter.stories.ts +3 -2
- package/src/web-components/input/gs-lineage-filter.tsx +2 -1
- package/src/web-components/input/gs-location-filter.stories.ts +3 -2
- package/src/web-components/input/gs-location-filter.tsx +2 -1
- package/src/web-components/input/gs-mutation-filter.stories.ts +3 -2
- package/src/web-components/input/gs-mutation-filter.tsx +2 -1
- package/src/web-components/input/gs-text-filter.stories.ts +6 -12
- package/src/web-components/input/gs-text-filter.tsx +2 -1
- package/src/web-components/visualization/gs-genome-data-viewer.spec-d.ts +18 -0
- package/src/web-components/visualization/gs-genome-data-viewer.stories.ts +108 -0
- package/src/web-components/visualization/gs-genome-data-viewer.tsx +59 -0
- package/src/web-components/visualization/index.ts +1 -0
- package/standalone-bundle/assets/mutationOverTimeWorker-jChgWnwp.js.map +1 -1
- package/standalone-bundle/dashboard-components.js +32674 -32374
- package/standalone-bundle/dashboard-components.js.map +1 -1
- 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,174 @@
|
|
|
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 getCDSMap(lines: string[], genome_type: string, geneMap: CDSMap): CDSMap {
|
|
64
|
+
for (const line of lines) {
|
|
65
|
+
if (line.startsWith('#') || line.trim() === '') {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const fields = line.split('\t');
|
|
70
|
+
if (fields.length < 9) {
|
|
71
|
+
throw new UserFacingError('Invalid gff3 source', `Gff3 line has less than 9 fields: "${line}"`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const [, , type, startStr, endStr, , , , attributes] = fields;
|
|
75
|
+
|
|
76
|
+
if (type.toLowerCase() !== genome_type.toLowerCase()) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const start = parseInt(startStr, 10);
|
|
81
|
+
const end = parseInt(endStr, 10);
|
|
82
|
+
|
|
83
|
+
if (isNaN(start) || isNaN(end)) {
|
|
84
|
+
throw new UserFacingError('Invalid gff3 source', `Invalid start or end position: "${line}"`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const attrPairs = getAttributes(attributes);
|
|
88
|
+
const label = attrPairs.get('Name') || attrPairs.get('gene') || attrPairs.get('gene_name');
|
|
89
|
+
if (!label) {
|
|
90
|
+
throw new UserFacingError(
|
|
91
|
+
'Invalid gff3 source',
|
|
92
|
+
`No label found for feature: "${line}", must contain label in Name, gene, or gene_name attribute`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
const id = attrPairs.get('ID') || label;
|
|
96
|
+
if (attrPairs.get('Parent')) {
|
|
97
|
+
const parent = attrPairs.get('Parent');
|
|
98
|
+
if (parent && parent in geneMap) {
|
|
99
|
+
delete geneMap[parent];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (id in geneMap) {
|
|
103
|
+
geneMap[id].positions.push({ start, end });
|
|
104
|
+
} else {
|
|
105
|
+
geneMap[id] = { positions: [{ start, end }], label };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return geneMap;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function getSortedCDSFeatures(cdsMap: CDSMap): CDSFeature[] {
|
|
112
|
+
const mapValues = Object.values(cdsMap);
|
|
113
|
+
|
|
114
|
+
mapValues.forEach((feature) => {
|
|
115
|
+
feature.positions.sort((a, b) => a.start - b.start);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
mapValues.sort((a, b) => {
|
|
119
|
+
return a.positions[0].start - b.positions[0].start;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const GraphColorList = Object.keys(ColorsRGB) as GraphColor[];
|
|
123
|
+
const colorIndex = mapValues[0].label[0].toUpperCase().charCodeAt(0);
|
|
124
|
+
|
|
125
|
+
const cdsFeatures: CDSFeature[] = mapValues.map((value, index) => ({
|
|
126
|
+
positions: value.positions,
|
|
127
|
+
label: value.label,
|
|
128
|
+
color: GraphColorList[(colorIndex + index) % GraphColorList.length],
|
|
129
|
+
}));
|
|
130
|
+
|
|
131
|
+
return cdsFeatures;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function getNonOverlappingCDSFeatures(cdsFeatures: CDSFeature[]): CDSFeature[][] {
|
|
135
|
+
const nonOverlappingCDSFeatures: CDSFeature[][] = [];
|
|
136
|
+
for (const cdsFeature of cdsFeatures) {
|
|
137
|
+
let added = false;
|
|
138
|
+
for (const cdsList of nonOverlappingCDSFeatures) {
|
|
139
|
+
const lastCds = cdsList[cdsList.length - 1];
|
|
140
|
+
if (cdsFeature.positions[0].start > lastCds.positions[lastCds.positions.length - 1].end) {
|
|
141
|
+
cdsList.push(cdsFeature);
|
|
142
|
+
added = true;
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!added) {
|
|
147
|
+
nonOverlappingCDSFeatures.push([cdsFeature]);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return nonOverlappingCDSFeatures;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function loadGenomeLength(content: string) {
|
|
154
|
+
const lines = content.split('\n');
|
|
155
|
+
for (const line of lines) {
|
|
156
|
+
if (!line.startsWith('#')) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const fields = line.split(' ');
|
|
160
|
+
if (fields[0] === '##sequence-region') {
|
|
161
|
+
if (fields.length < 3) {
|
|
162
|
+
throw new UserFacingError('Invalid gff3 source', `No length found in sequence-region: "${line}"`);
|
|
163
|
+
}
|
|
164
|
+
const start = fields.at(-2);
|
|
165
|
+
const end = fields.at(-1);
|
|
166
|
+
const length = parseInt(end!, 10) - parseInt(start!, 10) + 1;
|
|
167
|
+
if (isNaN(length)) {
|
|
168
|
+
throw new UserFacingError('Invalid gff3 source', `No length found in sequence-region: "${line}"`);
|
|
169
|
+
}
|
|
170
|
+
return length;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
throw new UserFacingError('Invalid gff3 source', `No length found in sequence-region`);
|
|
174
|
+
}
|
|
@@ -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(
|
|
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: [
|
|
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(
|
|
159
|
+
canvasElement.addEventListener(gsEventNames.lineageFilterChanged, lineageChangedListenerMock);
|
|
159
160
|
});
|
|
160
161
|
|
|
161
162
|
await step('location filter is rendered with value', async () => {
|