@genspectrum/dashboard-components 0.19.2 → 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 +71 -25
- package/dist/components.js +9047 -8699
- package/dist/components.js.map +1 -1
- package/dist/util.d.ts +51 -25
- package/dist/util.js +2 -1
- package/package.json +1 -1
- package/src/componentsEntrypoint.ts +3 -1
- 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/locationFilter/LocationChangedEvent.ts +2 -1
- package/src/preact/locationFilter/location-filter.stories.tsx +3 -2
- 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 +4 -3
- 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 +3 -2
- 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 +8275 -8002
- package/standalone-bundle/dashboard-components.js.map +1 -1
- package/dist/LineageFilterChangedEvent-ixHQkq8y.js.map +0 -1
|
@@ -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 () => {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { type LapisLocationFilter } from '../../types';
|
|
2
|
+
import { gsEventNames } from '../../utils/gsEventNames';
|
|
2
3
|
|
|
3
4
|
export class LocationChangedEvent extends CustomEvent<LapisLocationFilter> {
|
|
4
5
|
constructor(detail: LapisLocationFilter) {
|
|
5
|
-
super(
|
|
6
|
+
super(gsEventNames.locationChanged, {
|
|
6
7
|
detail,
|
|
7
8
|
bubbles: true,
|
|
8
9
|
composed: true,
|
|
@@ -6,6 +6,7 @@ import data from './__mockData__/aggregated.json';
|
|
|
6
6
|
import { LocationFilter, type LocationFilterProps } from './location-filter';
|
|
7
7
|
import { previewHandles } from '../../../.storybook/preview';
|
|
8
8
|
import { AGGREGATED_ENDPOINT, LAPIS_URL } from '../../constants';
|
|
9
|
+
import { gsEventNames } from '../../utils/gsEventNames';
|
|
9
10
|
import { LapisUrlContextProvider } from '../LapisUrlContext';
|
|
10
11
|
import { expectInvalidAttributesErrorMessage } from '../shared/stories/expectErrorMessage';
|
|
11
12
|
|
|
@@ -32,7 +33,7 @@ const meta: Meta<LocationFilterProps> = {
|
|
|
32
33
|
],
|
|
33
34
|
},
|
|
34
35
|
actions: {
|
|
35
|
-
handles: [
|
|
36
|
+
handles: [gsEventNames.locationChanged, ...previewHandles],
|
|
36
37
|
},
|
|
37
38
|
},
|
|
38
39
|
args: {
|
|
@@ -152,7 +153,7 @@ async function prepare(canvasElement: HTMLElement, step: StepFunction<PreactRend
|
|
|
152
153
|
|
|
153
154
|
const locationChangedListenerMock = fn();
|
|
154
155
|
await step('Setup event listener mock', () => {
|
|
155
|
-
canvasElement.addEventListener(
|
|
156
|
+
canvasElement.addEventListener(gsEventNames.locationChanged, locationChangedListenerMock);
|
|
156
157
|
});
|
|
157
158
|
|
|
158
159
|
await step('location filter is rendered with value', async () => {
|
|
@@ -6,6 +6,7 @@ import { MutationFilter, type MutationFilterProps } from './mutation-filter';
|
|
|
6
6
|
import { previewHandles } from '../../../.storybook/preview';
|
|
7
7
|
import { LAPIS_URL } from '../../constants';
|
|
8
8
|
import referenceGenome from '../../lapisApi/__mockData__/referenceGenome.json';
|
|
9
|
+
import { gsEventNames } from '../../utils/gsEventNames';
|
|
9
10
|
import { LapisUrlContextProvider } from '../LapisUrlContext';
|
|
10
11
|
import { ReferenceGenomeContext } from '../ReferenceGenomeContext';
|
|
11
12
|
import { playThatExpectsErrorMessage } from '../shared/stories/expectErrorMessage';
|
|
@@ -15,7 +16,7 @@ const meta: Meta<MutationFilterProps> = {
|
|
|
15
16
|
component: MutationFilter,
|
|
16
17
|
parameters: {
|
|
17
18
|
actions: {
|
|
18
|
-
handles: [
|
|
19
|
+
handles: [gsEventNames.mutationFilterChanged, ...previewHandles],
|
|
19
20
|
},
|
|
20
21
|
fetchMock: {},
|
|
21
22
|
},
|
|
@@ -357,7 +358,7 @@ async function prepare(canvasElement: HTMLElement, step: StepFunction<PreactRend
|
|
|
357
358
|
|
|
358
359
|
const changedListenerMock = fn();
|
|
359
360
|
await step('Setup event listener mock', () => {
|
|
360
|
-
canvasElement.addEventListener(
|
|
361
|
+
canvasElement.addEventListener(gsEventNames.mutationFilterChanged, changedListenerMock);
|
|
361
362
|
});
|
|
362
363
|
|
|
363
364
|
await step('wait until data is loaded', async () => {
|
|
@@ -8,6 +8,7 @@ import { MutationFilterInfo } from './mutation-filter-info';
|
|
|
8
8
|
import { parseAndValidateMutation } from './parseAndValidateMutation';
|
|
9
9
|
import { type ReferenceGenome } from '../../lapisApi/ReferenceGenome';
|
|
10
10
|
import { type MutationsFilter, mutationsFilterSchema } from '../../types';
|
|
11
|
+
import { gsEventNames } from '../../utils/gsEventNames';
|
|
11
12
|
import { type DeletionClass, type InsertionClass, type SubstitutionClass } from '../../utils/mutations';
|
|
12
13
|
import { ReferenceGenomeContext } from '../ReferenceGenomeContext';
|
|
13
14
|
import { ErrorBoundary } from '../components/error-boundary';
|
|
@@ -86,7 +87,7 @@ function MutationFilterInner({ initialValue }: MutationFilterInnerProps) {
|
|
|
86
87
|
const detail = mapToMutationFilterStrings(selectedFilters);
|
|
87
88
|
|
|
88
89
|
filterRef.current?.dispatchEvent(
|
|
89
|
-
new CustomEvent<MutationsFilter>(
|
|
90
|
+
new CustomEvent<MutationsFilter>(gsEventNames.mutationFilterChanged, {
|
|
90
91
|
detail,
|
|
91
92
|
bubbles: true,
|
|
92
93
|
composed: true,
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { gsEventNames } from '../../utils/gsEventNames';
|
|
2
|
+
|
|
1
3
|
type LapisTextFilter = Record<string, string | undefined>;
|
|
2
4
|
|
|
3
5
|
export class TextFilterChangedEvent extends CustomEvent<LapisTextFilter> {
|
|
4
6
|
constructor(detail: LapisTextFilter) {
|
|
5
|
-
super(
|
|
7
|
+
super(gsEventNames.textFilterChanged, {
|
|
6
8
|
detail,
|
|
7
9
|
bubbles: true,
|
|
8
10
|
composed: true,
|
|
@@ -5,6 +5,7 @@ import data from './__mockData__/aggregated_hosts.json';
|
|
|
5
5
|
import { TextFilter, type TextFilterProps } from './text-filter';
|
|
6
6
|
import { previewHandles } from '../../../.storybook/preview';
|
|
7
7
|
import { AGGREGATED_ENDPOINT, LAPIS_URL } from '../../constants';
|
|
8
|
+
import { gsEventNames } from '../../utils/gsEventNames';
|
|
8
9
|
import { LapisUrlContextProvider } from '../LapisUrlContext';
|
|
9
10
|
import { expectInvalidAttributesErrorMessage } from '../shared/stories/expectErrorMessage';
|
|
10
11
|
|
|
@@ -13,7 +14,7 @@ const meta: Meta<TextFilterProps> = {
|
|
|
13
14
|
component: TextFilter,
|
|
14
15
|
parameters: {
|
|
15
16
|
actions: {
|
|
16
|
-
handles: [
|
|
17
|
+
handles: [gsEventNames.textFilterChanged, ...previewHandles],
|
|
17
18
|
},
|
|
18
19
|
fetchMock: {
|
|
19
20
|
mocks: [
|
|
@@ -93,7 +94,7 @@ export const RemoveInitialValue: StoryObj<TextFilterProps> = {
|
|
|
93
94
|
|
|
94
95
|
const changedListenerMock = fn();
|
|
95
96
|
await step('Setup event listener mock', () => {
|
|
96
|
-
canvasElement.addEventListener(
|
|
97
|
+
canvasElement.addEventListener(gsEventNames.textFilterChanged, changedListenerMock);
|
|
97
98
|
});
|
|
98
99
|
|
|
99
100
|
await waitFor(async () => {
|
|
@@ -136,7 +137,7 @@ export const KeepsPartialInputInInputField: StoryObj<TextFilterProps> = {
|
|
|
136
137
|
|
|
137
138
|
const changedListenerMock = fn();
|
|
138
139
|
await step('Setup event listener mock', () => {
|
|
139
|
-
canvasElement.addEventListener(
|
|
140
|
+
canvasElement.addEventListener(gsEventNames.textFilterChanged, changedListenerMock);
|
|
140
141
|
});
|
|
141
142
|
const inputField = () => canvas.getByPlaceholderText('Enter a host name', { exact: false });
|
|
142
143
|
async function typeAndBlur(input: string) {
|
package/src/utilEntrypoint.ts
CHANGED
|
@@ -38,3 +38,5 @@ export { LineageFilterChangedEvent } from './preact/lineageFilter/LineageFilterC
|
|
|
38
38
|
export { TextFilterChangedEvent } from './preact/textFilter/TextFilterChangedEvent';
|
|
39
39
|
|
|
40
40
|
export type { MutationAnnotations, MutationAnnotation } from './web-components/mutation-annotations-context';
|
|
41
|
+
|
|
42
|
+
export { gsEventNames } from './utils/gsEventNames';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const gsEventNames = {
|
|
2
|
+
error: 'gs-error',
|
|
3
|
+
dateRangeFilterChanged: 'gs-date-range-filter-changed',
|
|
4
|
+
dateRangeOptionChanged: 'gs-date-range-option-changed',
|
|
5
|
+
mutationFilterChanged: 'gs-mutation-filter-changed',
|
|
6
|
+
lineageFilterChanged: 'gs-lineage-filter-changed',
|
|
7
|
+
locationChanged: 'gs-location-changed',
|
|
8
|
+
textFilterChanged: 'gs-text-filter-changed',
|
|
9
|
+
} as const;
|
|
@@ -10,6 +10,7 @@ import './gs-date-range-filter';
|
|
|
10
10
|
import '../gs-app';
|
|
11
11
|
import { toYYYYMMDD } from '../../preact/dateRangeFilter/dateConversion';
|
|
12
12
|
import { dateRangeOptionPresets } from '../../preact/dateRangeFilter/dateRangeOption';
|
|
13
|
+
import { gsEventNames } from '../../utils/gsEventNames';
|
|
13
14
|
import { withinShadowRoot } from '../withinShadowRoot.story';
|
|
14
15
|
|
|
15
16
|
const codeExample = String.raw`
|
|
@@ -29,7 +30,7 @@ const meta: Meta<Required<DateRangeFilterProps>> = {
|
|
|
29
30
|
component: 'gs-date-range-filter',
|
|
30
31
|
parameters: withComponentDocs({
|
|
31
32
|
actions: {
|
|
32
|
-
handles: [
|
|
33
|
+
handles: [gsEventNames.dateRangeFilterChanged, gsEventNames.dateRangeOptionChanged, ...previewHandles],
|
|
33
34
|
},
|
|
34
35
|
fetchMock: {},
|
|
35
36
|
componentDocs: {
|
|
@@ -144,8 +145,8 @@ export const FiresEvents: StoryObj<Required<DateRangeFilterProps>> = {
|
|
|
144
145
|
const filterChangedListenerMock = fn();
|
|
145
146
|
const optionChangedListenerMock = fn();
|
|
146
147
|
await step('Setup event listener mock', () => {
|
|
147
|
-
canvasElement.addEventListener(
|
|
148
|
-
canvasElement.addEventListener(
|
|
148
|
+
canvasElement.addEventListener(gsEventNames.dateRangeFilterChanged, filterChangedListenerMock);
|
|
149
|
+
canvasElement.addEventListener(gsEventNames.dateRangeOptionChanged, optionChangedListenerMock);
|
|
149
150
|
});
|
|
150
151
|
|
|
151
152
|
await step('Expect last 6 months to be selected', async () => {
|
|
@@ -5,6 +5,7 @@ import type { DetailedHTMLProps, HTMLAttributes } from 'react';
|
|
|
5
5
|
|
|
6
6
|
import { DateRangeFilter, type DateRangeFilterProps } from '../../preact/dateRangeFilter/date-range-filter';
|
|
7
7
|
import { type DateRangeOptionChangedEvent } from '../../preact/dateRangeFilter/dateRangeOption';
|
|
8
|
+
import { type gsEventNames } from '../../utils/gsEventNames';
|
|
8
9
|
import { type Equals, type Expect } from '../../utils/typeAssertions';
|
|
9
10
|
import { PreactLitAdapter } from '../PreactLitAdapter';
|
|
10
11
|
|
|
@@ -143,8 +144,8 @@ declare global {
|
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
interface HTMLElementEventMap {
|
|
146
|
-
|
|
147
|
-
|
|
147
|
+
[gsEventNames.dateRangeFilterChanged]: CustomEvent<Record<string, string>>;
|
|
148
|
+
[gsEventNames.dateRangeOptionChanged]: DateRangeOptionChangedEvent;
|
|
148
149
|
}
|
|
149
150
|
}
|
|
150
151
|
|