@genspectrum/dashboard-components 0.10.3 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/custom-elements.json +384 -56
  2. package/dist/assets/mutationOverTimeWorker-BjjkMGzd.js.map +1 -0
  3. package/dist/components.d.ts +250 -31
  4. package/dist/components.js +1220 -198
  5. package/dist/components.js.map +1 -1
  6. package/dist/{dateRangeOption-DjtcAEWq.js → dateRangeOption-Bh2p78z0.js} +11 -5
  7. package/dist/dateRangeOption-Bh2p78z0.js.map +1 -0
  8. package/dist/style.css +5 -1
  9. package/dist/util.d.ts +626 -16
  10. package/dist/util.js +1 -1
  11. package/package.json +13 -7
  12. package/src/preact/aggregatedData/aggregate.stories.tsx +2 -2
  13. package/src/preact/aggregatedData/aggregate.tsx +11 -8
  14. package/src/preact/dateRangeSelector/date-range-selector.stories.tsx +4 -12
  15. package/src/preact/dateRangeSelector/date-range-selector.tsx +4 -4
  16. package/src/preact/lineageFilter/lineage-filter.stories.tsx +1 -1
  17. package/src/preact/locationFilter/location-filter.stories.tsx +1 -1
  18. package/src/preact/map/__mockData__/aggregatedGermany.json +83 -0
  19. package/src/preact/map/__mockData__/aggregatedWorld.json +259 -0
  20. package/src/preact/map/__mockData__/germanyMap.json +9083 -0
  21. package/src/preact/map/__mockData__/howToGenerateWorldMap.md +9 -0
  22. package/src/preact/map/__mockData__/worldAtlas.json +497127 -0
  23. package/src/preact/map/leafletStyleModifications.css +3 -0
  24. package/src/preact/map/sequences-by-location-map.tsx +202 -0
  25. package/src/preact/map/sequences-by-location-table.tsx +18 -0
  26. package/src/preact/map/sequences-by-location.stories.tsx +144 -0
  27. package/src/preact/map/sequences-by-location.tsx +151 -0
  28. package/src/preact/map/useGeoJsonMap.tsx +62 -0
  29. package/src/preact/mutationComparison/mutation-comparison.tsx +5 -7
  30. package/src/preact/mutationFilter/mutation-filter.tsx +4 -13
  31. package/src/preact/mutations/mutations.tsx +4 -4
  32. package/src/preact/mutationsOverTime/mutations-over-time.stories.tsx +1 -1
  33. package/src/preact/mutationsOverTime/mutations-over-time.tsx +4 -4
  34. package/src/preact/numberSequencesOverTime/number-sequences-over-time.stories.tsx +5 -14
  35. package/src/preact/numberSequencesOverTime/number-sequences-over-time.tsx +4 -4
  36. package/src/preact/prevalenceOverTime/prevalence-over-time.stories.tsx +15 -26
  37. package/src/preact/prevalenceOverTime/prevalence-over-time.tsx +8 -8
  38. package/src/preact/relativeGrowthAdvantage/relative-growth-advantage.stories.tsx +4 -15
  39. package/src/preact/relativeGrowthAdvantage/relative-growth-advantage.tsx +11 -7
  40. package/src/preact/shared/stories/expectErrorMessage.ts +21 -0
  41. package/src/preact/textInput/text-input.stories.tsx +1 -1
  42. package/src/preact/useQuery.ts +9 -1
  43. package/src/query/queryNumberOfSequencesOverTime.spec.ts +4 -4
  44. package/src/query/queryNumberOfSequencesOverTime.ts +1 -4
  45. package/src/query/queryPrevalenceOverTime.ts +1 -4
  46. package/src/styles/tailwind.css +1 -1
  47. package/src/types.ts +12 -4
  48. package/src/utilEntrypoint.ts +16 -4
  49. package/src/utils/utils.ts +0 -29
  50. package/src/web-components/app.ts +1 -1
  51. package/src/web-components/input/gs-date-range-selector.stories.ts +4 -4
  52. package/src/web-components/input/gs-date-range-selector.tsx +5 -5
  53. package/src/web-components/input/gs-lineage-filter.tsx +1 -1
  54. package/src/web-components/input/gs-location-filter.tsx +1 -1
  55. package/src/web-components/input/gs-mutation-filter.tsx +5 -8
  56. package/src/web-components/input/gs-text-input.tsx +1 -1
  57. package/src/web-components/visualization/gs-aggregate.stories.ts +3 -3
  58. package/src/web-components/visualization/gs-aggregate.tsx +10 -6
  59. package/src/web-components/visualization/gs-mutation-comparison.tsx +7 -2
  60. package/src/web-components/visualization/gs-mutations-over-time.tsx +7 -2
  61. package/src/web-components/visualization/gs-mutations.tsx +7 -2
  62. package/src/web-components/visualization/gs-number-sequences-over-time.stories.ts +5 -5
  63. package/src/web-components/visualization/gs-number-sequences-over-time.tsx +13 -15
  64. package/src/web-components/visualization/gs-prevalence-over-time.stories.ts +8 -8
  65. package/src/web-components/visualization/gs-prevalence-over-time.tsx +17 -14
  66. package/src/web-components/visualization/gs-relative-growth-advantage.stories.ts +4 -5
  67. package/src/web-components/visualization/gs-relative-growth-advantage.tsx +17 -15
  68. package/src/web-components/visualization/gs-sequences-by-location.stories.ts +234 -0
  69. package/src/web-components/visualization/gs-sequences-by-location.tsx +258 -0
  70. package/src/web-components/visualization/gs-statistics.tsx +12 -3
  71. package/src/web-components/visualization/index.ts +1 -0
  72. package/standalone-bundle/assets/mutationOverTimeWorker-DoUBht2e.js.map +1 -0
  73. package/standalone-bundle/dashboard-components.js +16208 -9408
  74. package/standalone-bundle/dashboard-components.js.map +1 -1
  75. package/standalone-bundle/style.css +1 -1
  76. package/dist/assets/mutationOverTimeWorker-CNg_ztNp.js.map +0 -1
  77. package/dist/dateRangeOption-DjtcAEWq.js.map +0 -1
  78. package/src/preact/shared/stories/expectInvalidAttributesErrorMessage.ts +0 -13
  79. package/src/utils/utils.spec.ts +0 -16
  80. package/standalone-bundle/assets/mutationOverTimeWorker-cIyshfj_.js.map +0 -1
@@ -0,0 +1,3 @@
1
+ .leaflet-container {
2
+ background: transparent;
3
+ }
@@ -0,0 +1,202 @@
1
+ import type { Feature, FeatureCollection, GeometryObject } from 'geojson';
2
+ import Leaflet, { type Layer, type LayerGroup } from 'leaflet';
3
+ import type { FunctionComponent } from 'preact';
4
+ import { useEffect, useRef } from 'preact/hooks';
5
+
6
+ import { type GeoJsonFeatureProperties, type MapSource, useGeoJsonMap } from './useGeoJsonMap';
7
+ import { type AggregateData } from '../../query/queryAggregateData';
8
+ import { LoadingDisplay } from '../components/loading-display';
9
+ import { formatProportion } from '../shared/table/formatProportion';
10
+
11
+ type FeatureData = { proportion: number; count: number };
12
+
13
+ type EnhancedGeoJsonFeatureProperties = GeoJsonFeatureProperties & {
14
+ data: FeatureData | null;
15
+ };
16
+
17
+ type SequencesByLocationMapProps = {
18
+ mapSource: MapSource;
19
+ locationData: AggregateData;
20
+ enableMapNavigation: boolean;
21
+ lapisLocationField: string;
22
+ zoom: number;
23
+ offsetX: number;
24
+ offsetY: number;
25
+ };
26
+
27
+ export const SequencesByLocationMap: FunctionComponent<SequencesByLocationMapProps> = ({
28
+ mapSource,
29
+ ...otherProps
30
+ }) => {
31
+ const { isLoading: isLoadingMap, geojsonData } = useGeoJsonMap(mapSource);
32
+
33
+ if (isLoadingMap) {
34
+ return <LoadingDisplay />;
35
+ }
36
+
37
+ return <SequencesByLocationMapInner geojsonData={geojsonData} {...otherProps} />;
38
+ };
39
+
40
+ type SequencesByLocationMapInnerProps = Omit<SequencesByLocationMapProps, 'mapSource'> & {
41
+ geojsonData: FeatureCollection<GeometryObject, GeoJsonFeatureProperties>;
42
+ };
43
+
44
+ export const SequencesByLocationMapInner: FunctionComponent<SequencesByLocationMapInnerProps> = ({
45
+ geojsonData,
46
+ locationData,
47
+ enableMapNavigation,
48
+ lapisLocationField,
49
+ zoom,
50
+ offsetX,
51
+ offsetY,
52
+ }) => {
53
+ const ref = useRef<HTMLDivElement>(null);
54
+
55
+ useEffect(() => {
56
+ if (!ref.current || geojsonData === undefined || locationData === undefined) {
57
+ return;
58
+ }
59
+
60
+ const countAndProportionByCountry = buildLookupByLocationField(locationData, lapisLocationField);
61
+ const locations = matchLocationDataAndGeoJsonFeatures(
62
+ geojsonData,
63
+ countAndProportionByCountry,
64
+ lapisLocationField,
65
+ );
66
+
67
+ const leafletMap = Leaflet.map(ref.current, {
68
+ scrollWheelZoom: enableMapNavigation,
69
+ zoomControl: enableMapNavigation,
70
+ keyboard: enableMapNavigation,
71
+ dragging: enableMapNavigation,
72
+ zoomSnap: 0,
73
+ zoom,
74
+ center: [offsetY, offsetX],
75
+ });
76
+
77
+ Leaflet.geoJson(locations, {
78
+ style: (feature: Feature<GeometryObject, EnhancedGeoJsonFeatureProperties> | undefined) => ({
79
+ fillColor: getColor(feature?.properties.data?.proportion),
80
+ fillOpacity: 1,
81
+ color: 'grey',
82
+ weight: 1,
83
+ }),
84
+ })
85
+ .bindTooltip(createTooltip)
86
+ .addTo(leafletMap);
87
+
88
+ return () => {
89
+ leafletMap.remove();
90
+ };
91
+ }, [ref, locationData, geojsonData, enableMapNavigation, lapisLocationField, zoom, offsetX, offsetY]);
92
+
93
+ return <div ref={ref} className='h-full' />;
94
+ };
95
+
96
+ function buildLookupByLocationField(locationData: AggregateData, lapisLocationField: string) {
97
+ return new Map<string, FeatureData>(
98
+ locationData
99
+ .filter((row) => typeof row[lapisLocationField] === 'string')
100
+ .map((row) => [row[lapisLocationField] as string, row]),
101
+ );
102
+ }
103
+
104
+ function matchLocationDataAndGeoJsonFeatures(
105
+ geojsonData: FeatureCollection<GeometryObject, GeoJsonFeatureProperties>,
106
+ countAndProportionByCountry: Map<string, FeatureData>,
107
+ lapisLocationField: string,
108
+ ) {
109
+ const matchedLocations: string[] = [];
110
+
111
+ const locations: Feature<GeometryObject, EnhancedGeoJsonFeatureProperties>[] = geojsonData.features.map(
112
+ (feature) => {
113
+ const name = feature?.properties?.name;
114
+ if (typeof name !== 'string') {
115
+ throw new Error(
116
+ `GeoJSON feature with id '${feature.id}' does not have 'properties.name' of type string, was: '${name}'`,
117
+ );
118
+ }
119
+
120
+ const data = countAndProportionByCountry.get(name) ?? null;
121
+ if (data !== null) {
122
+ matchedLocations.push(name);
123
+ }
124
+ return {
125
+ ...feature,
126
+ properties: {
127
+ ...feature.properties,
128
+ data,
129
+ },
130
+ };
131
+ },
132
+ );
133
+
134
+ const unmatchedLocations = [...countAndProportionByCountry.keys()].filter(
135
+ (name) => !matchedLocations.includes(name),
136
+ );
137
+ if (unmatchedLocations.length > 0) {
138
+ const unmatchedLocationsWarning = `gs-map: Found location data from LAPIS (aggregated by "${lapisLocationField}") that could not be matched on locations on the given map. Unmatched location names are: ${unmatchedLocations.map((it) => `"${it}"`).join(', ')}`;
139
+ console.warn(unmatchedLocationsWarning); // eslint-disable-line no-console -- We should give some feedback about unmatched location data.
140
+ }
141
+
142
+ return locations;
143
+ }
144
+
145
+ function getColor(value: number | undefined): string {
146
+ if (value === undefined) {
147
+ return '#888888';
148
+ }
149
+
150
+ const thresholds = [
151
+ { limit: 0.4, color: '#662506' },
152
+ { limit: 0.3, color: '#993404' },
153
+ { limit: 0.2, color: '#CC4C02' },
154
+ { limit: 0.1, color: '#EC7014' },
155
+ { limit: 0.05, color: '#FB9A29' },
156
+ { limit: 0.02, color: '#FEC44F' },
157
+ { limit: 0.01, color: '#FEE391' },
158
+ { limit: 0.005, color: '#FFF7BC' },
159
+ { limit: 0.002, color: '#FFFFE5' },
160
+ ];
161
+
162
+ for (const { limit, color } of thresholds) {
163
+ if (value > limit) {
164
+ return color;
165
+ }
166
+ }
167
+
168
+ return '#FFFFE5';
169
+ }
170
+
171
+ function createTooltip(layer: Layer) {
172
+ const feature = (layer as LayerGroup<EnhancedGeoJsonFeatureProperties>).feature;
173
+ if (feature === undefined || feature.type !== 'Feature') {
174
+ return '';
175
+ }
176
+ const properties = feature.properties;
177
+
178
+ const div = document.createElement('div');
179
+ div.appendChild(p({ innerText: properties.name, className: 'font-bold' }));
180
+ if (properties.data !== null) {
181
+ div.appendChild(
182
+ p({
183
+ innerText: `Count: ${properties.data.count.toLocaleString('en-us')}`,
184
+ }),
185
+ );
186
+ div.appendChild(
187
+ p({
188
+ innerText: `Proportion: ${formatProportion(properties.data.proportion)}`,
189
+ }),
190
+ );
191
+ } else {
192
+ div.appendChild(p({ innerText: 'No data' }));
193
+ }
194
+ return div;
195
+ }
196
+
197
+ function p({ innerText, className = '' }: { innerText: string; className?: string }) {
198
+ const headline = document.createElement('p');
199
+ headline.innerText = innerText;
200
+ headline.className = className;
201
+ return headline;
202
+ }
@@ -0,0 +1,18 @@
1
+ import { type FunctionComponent } from 'preact';
2
+
3
+ import type { AggregateData } from '../../query/queryAggregateData';
4
+ import { AggregateTable } from '../aggregatedData/aggregate-table';
5
+
6
+ type SequencesByLocationTableProps = {
7
+ locationData: AggregateData;
8
+ lapisLocationField: string;
9
+ pageSize: boolean | number;
10
+ };
11
+
12
+ export const SequencesByLocationTable: FunctionComponent<SequencesByLocationTableProps> = ({
13
+ locationData,
14
+ lapisLocationField,
15
+ pageSize,
16
+ }) => {
17
+ return <AggregateTable data={locationData} fields={[lapisLocationField]} pageSize={pageSize} />;
18
+ };
@@ -0,0 +1,144 @@
1
+ import { type Meta, type StoryObj } from '@storybook/preact';
2
+
3
+ import worldAtlas from './__mockData__/worldAtlas.json';
4
+ import { AGGREGATED_ENDPOINT, LAPIS_URL } from '../../constants';
5
+ import { LapisUrlContext } from '../LapisUrlContext';
6
+ import aggregatedWorld from './__mockData__/aggregatedWorld.json';
7
+ import { SequencesByLocation, type SequencesByLocationProps } from './sequences-by-location';
8
+ import { expectInvalidAttributesErrorMessage, playThatExpectsErrorMessage } from '../shared/stories/expectErrorMessage';
9
+
10
+ import 'leaflet/dist/leaflet.css';
11
+ import './leafletStyleModifications.css';
12
+
13
+ const meta: Meta<SequencesByLocationProps> = {
14
+ title: 'Visualization/SequencesByLocation',
15
+ component: SequencesByLocation,
16
+ };
17
+
18
+ export default meta;
19
+
20
+ const worldMapUrl = 'https://mock.map.data/world.topo.json';
21
+
22
+ const aggregatedWorldMatcher = {
23
+ matcher: {
24
+ name: 'aggregatedData',
25
+ url: AGGREGATED_ENDPOINT,
26
+ body: {
27
+ fields: ['country'],
28
+ dateFrom: '2022-01-01',
29
+ dateTo: '2022-04-01',
30
+ },
31
+ },
32
+ response: {
33
+ status: 200,
34
+ body: aggregatedWorld,
35
+ },
36
+ };
37
+
38
+ export const Default: StoryObj<SequencesByLocationProps> = {
39
+ render: (args) => (
40
+ <LapisUrlContext.Provider value={LAPIS_URL}>
41
+ <SequencesByLocation {...args} />
42
+ </LapisUrlContext.Provider>
43
+ ),
44
+ args: {
45
+ lapisFilter: { dateFrom: '2022-01-01', dateTo: '2022-04-01' },
46
+ lapisLocationField: 'country',
47
+ mapSource: {
48
+ type: 'topojson',
49
+ url: worldMapUrl,
50
+ topologyObjectsKey: 'countries',
51
+ },
52
+ enableMapNavigation: false,
53
+ width: '1100px',
54
+ height: '800px',
55
+ views: ['map', 'table'],
56
+ zoom: 2,
57
+ offsetX: 0,
58
+ offsetY: 10,
59
+ pageSize: 10,
60
+ },
61
+ parameters: {
62
+ fetchMock: {
63
+ mocks: [
64
+ {
65
+ matcher: {
66
+ name: 'worldMap',
67
+ url: worldMapUrl,
68
+ },
69
+ response: {
70
+ status: 200,
71
+ body: worldAtlas,
72
+ },
73
+ },
74
+ aggregatedWorldMatcher,
75
+ ],
76
+ },
77
+ },
78
+ };
79
+
80
+ export const InvalidTopoJsonTopology: StoryObj<SequencesByLocationProps> = {
81
+ ...Default,
82
+ parameters: {
83
+ fetchMock: {
84
+ mocks: [
85
+ {
86
+ matcher: {
87
+ name: 'worldMap',
88
+ url: worldMapUrl,
89
+ },
90
+ response: {
91
+ status: 200,
92
+ body: { type: 'not a topology' },
93
+ },
94
+ },
95
+ aggregatedWorldMatcher,
96
+ ],
97
+ },
98
+ },
99
+ play: playThatExpectsErrorMessage(
100
+ 'Error - Invalid map source',
101
+ `does not look like a topojson Topology definition: missing 'type: "Topology"'`,
102
+ ),
103
+ };
104
+
105
+ export const InvalidTopoJsonObjects: StoryObj<SequencesByLocationProps> = {
106
+ ...Default,
107
+ parameters: {
108
+ fetchMock: {
109
+ mocks: [
110
+ {
111
+ matcher: {
112
+ name: 'worldMap',
113
+ url: worldMapUrl,
114
+ },
115
+ response: {
116
+ status: 200,
117
+ body: { type: 'Topology', objects: 'invalid topology objects' },
118
+ },
119
+ },
120
+ aggregatedWorldMatcher,
121
+ ],
122
+ },
123
+ },
124
+ play: playThatExpectsErrorMessage(
125
+ 'Error - Invalid map source',
126
+ `does not have a GeometryCollection at key objects.countries`,
127
+ ),
128
+ };
129
+
130
+ export const InvalidProps: StoryObj<SequencesByLocationProps> = {
131
+ ...Default,
132
+ args: {
133
+ ...Default.args,
134
+ lapisLocationField: '',
135
+ },
136
+ play: async ({ canvasElement, step }) => {
137
+ step('expect error message', async () => {
138
+ await expectInvalidAttributesErrorMessage(
139
+ canvasElement,
140
+ '"lapisLocationField": String must contain at least 1 character(s)',
141
+ );
142
+ });
143
+ },
144
+ };
@@ -0,0 +1,151 @@
1
+ import type { FunctionComponent } from 'preact';
2
+ import { useContext } from 'preact/hooks';
3
+ import z from 'zod';
4
+
5
+ import { SequencesByLocationMap } from './sequences-by-location-map';
6
+ import { SequencesByLocationTable } from './sequences-by-location-table';
7
+ import { type AggregateData, queryAggregateData } from '../../query/queryAggregateData';
8
+ import { LapisUrlContext } from '../LapisUrlContext';
9
+ import { ErrorBoundary } from '../components/error-boundary';
10
+ import { Fullscreen } from '../components/fullscreen';
11
+ import Info, { InfoComponentCode, InfoHeadline1, InfoParagraph } from '../components/info';
12
+ import { LoadingDisplay } from '../components/loading-display';
13
+ import { ResizeContainer } from '../components/resize-container';
14
+ import { useQuery } from '../useQuery';
15
+ import { mapSourceSchema } from './useGeoJsonMap';
16
+ import { lapisFilterSchema, views } from '../../types';
17
+ import Tabs from '../components/tabs';
18
+
19
+ export const sequencesByLocationViewSchema = z.union([z.literal(views.map), z.literal(views.table)]);
20
+ export type SequencesByLocationMapView = z.infer<typeof sequencesByLocationViewSchema>;
21
+
22
+ const sequencesByLocationPropsSchema = z.object({
23
+ lapisFilter: lapisFilterSchema,
24
+ lapisLocationField: z.string().min(1),
25
+ mapSource: mapSourceSchema.optional(),
26
+ enableMapNavigation: z.boolean(),
27
+ width: z.string(),
28
+ height: z.string(),
29
+ views: z.array(sequencesByLocationViewSchema),
30
+ zoom: z.number(),
31
+ offsetX: z.number(),
32
+ offsetY: z.number(),
33
+ pageSize: z.union([z.boolean(), z.number()]),
34
+ });
35
+
36
+ export type SequencesByLocationProps = z.infer<typeof sequencesByLocationPropsSchema>;
37
+
38
+ export const SequencesByLocation: FunctionComponent<SequencesByLocationProps> = (componentProps) => {
39
+ const { width, height } = componentProps;
40
+ const size = { height, width };
41
+
42
+ return (
43
+ <ErrorBoundary size={size} componentProps={componentProps} schema={sequencesByLocationPropsSchema}>
44
+ <ResizeContainer size={size}>
45
+ <SequencesByLocationMapInner {...componentProps} />
46
+ </ResizeContainer>
47
+ </ErrorBoundary>
48
+ );
49
+ };
50
+
51
+ const SequencesByLocationMapInner: FunctionComponent<SequencesByLocationProps> = (props) => {
52
+ const { lapisFilter, lapisLocationField } = props;
53
+
54
+ const lapis = useContext(LapisUrlContext);
55
+ const {
56
+ data,
57
+ error,
58
+ isLoading: isLoadingLapisData,
59
+ } = useQuery(
60
+ async () => queryAggregateData(lapisFilter, [lapisLocationField], lapis),
61
+ [lapisFilter, lapisLocationField, lapis],
62
+ );
63
+
64
+ if (isLoadingLapisData) {
65
+ return <LoadingDisplay />;
66
+ }
67
+
68
+ if (error) {
69
+ throw error;
70
+ }
71
+
72
+ return <SequencesByLocationMapTabs data={data} originalComponentProps={props} />;
73
+ };
74
+
75
+ type SequencesByLocationMapTabsProps = {
76
+ originalComponentProps: SequencesByLocationProps;
77
+ data: AggregateData;
78
+ };
79
+
80
+ const SequencesByLocationMapTabs: FunctionComponent<SequencesByLocationMapTabsProps> = ({
81
+ originalComponentProps,
82
+ data,
83
+ }) => {
84
+ const getTab = (view: SequencesByLocationMapView) => {
85
+ switch (view) {
86
+ case views.map:
87
+ if (originalComponentProps.mapSource === undefined) {
88
+ throw new Error('mapSource is required when using the map view');
89
+ }
90
+ return {
91
+ title: 'Map',
92
+ content: (
93
+ <SequencesByLocationMap
94
+ locationData={data}
95
+ mapSource={originalComponentProps.mapSource}
96
+ enableMapNavigation={originalComponentProps.enableMapNavigation}
97
+ lapisLocationField={originalComponentProps.lapisLocationField}
98
+ zoom={originalComponentProps.zoom}
99
+ offsetX={originalComponentProps.offsetX}
100
+ offsetY={originalComponentProps.offsetY}
101
+ />
102
+ ),
103
+ };
104
+ case views.table:
105
+ return {
106
+ title: 'Table',
107
+ content: (
108
+ <SequencesByLocationTable
109
+ locationData={data}
110
+ lapisLocationField={originalComponentProps.lapisLocationField}
111
+ pageSize={originalComponentProps.pageSize}
112
+ />
113
+ ),
114
+ };
115
+ }
116
+ };
117
+
118
+ const tabs = originalComponentProps.views.map((view) => getTab(view));
119
+
120
+ return <Tabs tabs={tabs} toolbar={<Toolbar originalComponentProps={originalComponentProps} />} />;
121
+ };
122
+
123
+ type ToolbarProps = {
124
+ originalComponentProps: SequencesByLocationProps;
125
+ };
126
+
127
+ const Toolbar: FunctionComponent<ToolbarProps> = ({ originalComponentProps }) => {
128
+ return (
129
+ <div class='flex flex-row'>
130
+ <SequencesByLocationMapInfo originalComponentProps={originalComponentProps} />
131
+ <Fullscreen />
132
+ </div>
133
+ );
134
+ };
135
+
136
+ type SequencesByLocationMapInfoProps = {
137
+ originalComponentProps: SequencesByLocationProps;
138
+ };
139
+
140
+ const SequencesByLocationMapInfo: FunctionComponent<SequencesByLocationMapInfoProps> = ({ originalComponentProps }) => {
141
+ const lapis = useContext(LapisUrlContext);
142
+ return (
143
+ <Info>
144
+ <InfoHeadline1>Prevalence by location</InfoHeadline1>
145
+ <InfoParagraph>
146
+ TODO: Add description https://github.com/GenSpectrum/dashboard-components/issues/598
147
+ </InfoParagraph>
148
+ <InfoComponentCode componentName='sequences-by-location' params={originalComponentProps} lapisUrl={lapis} />
149
+ </Info>
150
+ );
151
+ };
@@ -0,0 +1,62 @@
1
+ import type { FeatureCollection, GeometryObject } from 'geojson';
2
+ import * as topojson from 'topojson-client';
3
+ import type { GeometryCollection, Topology } from 'topojson-specification';
4
+ import z from 'zod';
5
+
6
+ import { UserFacingError } from '../components/error-display';
7
+ import { useQuery } from '../useQuery';
8
+
9
+ export const mapSourceSchema = z.object({
10
+ type: z.literal('topojson'),
11
+ url: z.string().min(1),
12
+ topologyObjectsKey: z.string().min(1),
13
+ });
14
+ export type MapSource = z.infer<typeof mapSourceSchema>;
15
+
16
+ export function useGeoJsonMap(mapSource: MapSource) {
17
+ const {
18
+ data: geojsonData,
19
+ error,
20
+ isLoading,
21
+ } = useQuery(async () => {
22
+ switch (mapSource.type) {
23
+ case 'topojson':
24
+ return await loadTopojsonMap(mapSource);
25
+ }
26
+ }, [mapSource]);
27
+
28
+ if (isLoading) {
29
+ return { isLoading };
30
+ }
31
+
32
+ if (error) {
33
+ throw error;
34
+ }
35
+
36
+ return { geojsonData, isLoading: false as const };
37
+ }
38
+
39
+ export type GeoJsonFeatureProperties = {
40
+ name: string;
41
+ };
42
+
43
+ async function loadTopojsonMap(
44
+ mapSource: MapSource,
45
+ ): Promise<FeatureCollection<GeometryObject, GeoJsonFeatureProperties>> {
46
+ const response = await fetch(mapSource.url);
47
+ const topology = (await response.json()) as Topology;
48
+ if (topology?.type !== 'Topology') {
49
+ throw new UserFacingError(
50
+ 'Invalid map source',
51
+ `JSON downloaded from ${mapSource.url} does not look like a topojson Topology definition: missing 'type: "Topology"', got '${JSON.stringify(topology).substring(0, 100)}'`,
52
+ );
53
+ }
54
+ const object = topology?.objects[mapSource.topologyObjectsKey] as GeometryCollection<GeoJsonFeatureProperties>;
55
+ if (object?.type !== 'GeometryCollection') {
56
+ throw new UserFacingError(
57
+ 'Invalid map source',
58
+ `JSON downloaded from ${mapSource.url} does not have a GeometryCollection at key objects.${mapSource.topologyObjectsKey}, got '${JSON.stringify(topology)?.substring(0, 100)}'`,
59
+ );
60
+ }
61
+ return topojson.feature(topology, object);
62
+ }
@@ -6,12 +6,7 @@ import { getMutationComparisonTableData } from './getMutationComparisonTableData
6
6
  import { MutationComparisonTable } from './mutation-comparison-table';
7
7
  import { MutationComparisonVenn } from './mutation-comparison-venn';
8
8
  import { filterMutationData, type MutationData, queryMutationData } from './queryMutationData';
9
- import {
10
- type MutationComparisonView,
11
- mutationComparisonViewSchema,
12
- namedLapisFilterSchema,
13
- sequenceTypeSchema,
14
- } from '../../types';
9
+ import { namedLapisFilterSchema, sequenceTypeSchema, views } from '../../types';
15
10
  import { LapisUrlContext } from '../LapisUrlContext';
16
11
  import { CsvDownloadButton } from '../components/csv-download-button';
17
12
  import { ErrorBoundary } from '../components/error-boundary';
@@ -27,6 +22,9 @@ import { type DisplayedSegment, SegmentSelector, useDisplayedSegments } from '..
27
22
  import Tabs from '../components/tabs';
28
23
  import { useQuery } from '../useQuery';
29
24
 
25
+ export const mutationComparisonViewSchema = z.union([z.literal(views.table), z.literal(views.venn)]);
26
+ export type MutationComparisonView = z.infer<typeof mutationComparisonViewSchema>;
27
+
30
28
  const mutationComparisonPropsSchema = z.object({
31
29
  width: z.string(),
32
30
  height: z.string(),
@@ -51,7 +49,7 @@ export const MutationComparison: FunctionComponent<MutationComparisonProps> = (c
51
49
  );
52
50
  };
53
51
 
54
- export const MutationComparisonInner: FunctionComponent<MutationComparisonProps> = (componentProps) => {
52
+ const MutationComparisonInner: FunctionComponent<MutationComparisonProps> = (componentProps) => {
55
53
  const { lapisFilters, sequenceType } = componentProps;
56
54
  const lapis = useContext(LapisUrlContext);
57
55
 
@@ -5,20 +5,14 @@ import z from 'zod';
5
5
  import { MutationFilterInfo } from './mutation-filter-info';
6
6
  import { parseAndValidateMutation, type ParsedMutationFilter } from './parseAndValidateMutation';
7
7
  import { type ReferenceGenome } from '../../lapisApi/ReferenceGenome';
8
+ import { type MutationsFilter, mutationsFilterSchema } from '../../types';
8
9
  import { type DeletionClass, type InsertionClass, type SubstitutionClass } from '../../utils/mutations';
9
10
  import { ReferenceGenomeContext } from '../ReferenceGenomeContext';
10
11
  import { ErrorBoundary } from '../components/error-boundary';
11
12
  import { singleGraphColorRGBByName } from '../shared/charts/colors';
12
13
 
13
- const selectedMutationFilterStringsSchema = z.object({
14
- nucleotideMutations: z.array(z.string()),
15
- aminoAcidMutations: z.array(z.string()),
16
- nucleotideInsertions: z.array(z.string()),
17
- aminoAcidInsertions: z.array(z.string()),
18
- });
19
- export type SelectedMutationFilterStrings = z.infer<typeof selectedMutationFilterStringsSchema>;
20
14
  const mutationFilterInnerPropsSchema = z.object({
21
- initialValue: z.union([selectedMutationFilterStringsSchema.optional(), z.array(z.string()), z.undefined()]),
15
+ initialValue: z.union([mutationsFilterSchema.optional(), z.array(z.string()), z.undefined()]),
22
16
  });
23
17
 
24
18
  const mutationFilterPropsSchema = mutationFilterInnerPropsSchema.extend({
@@ -73,7 +67,7 @@ export const MutationFilterInner: FunctionComponent<MutationFilterInnerProps> =
73
67
  const detail = mapToMutationFilterStrings(selectedFilters);
74
68
 
75
69
  filterRef.current?.dispatchEvent(
76
- new CustomEvent<SelectedMutationFilterStrings>('gs-mutation-filter-changed', {
70
+ new CustomEvent<MutationsFilter>('gs-mutation-filter-changed', {
77
71
  detail,
78
72
  bubbles: true,
79
73
  composed: true,
@@ -105,10 +99,7 @@ export const MutationFilterInner: FunctionComponent<MutationFilterInnerProps> =
105
99
  );
106
100
  };
107
101
 
108
- function getInitialState(
109
- initialValue: SelectedMutationFilterStrings | string[] | undefined,
110
- referenceGenome: ReferenceGenome,
111
- ) {
102
+ function getInitialState(initialValue: MutationsFilter | string[] | undefined, referenceGenome: ReferenceGenome) {
112
103
  if (initialValue === undefined) {
113
104
  return {
114
105
  nucleotideMutations: [],
@@ -30,13 +30,13 @@ import { type DisplayedSegment, SegmentSelector, useDisplayedSegments } from '..
30
30
  import Tabs from '../components/tabs';
31
31
  import { useQuery } from '../useQuery';
32
32
 
33
- const viewSchema = z.union([z.literal(views.table), z.literal(views.grid), z.literal(views.insertions)]);
34
- export type View = z.infer<typeof viewSchema>;
33
+ const mutationsViewSchema = z.union([z.literal(views.table), z.literal(views.grid), z.literal(views.insertions)]);
34
+ export type MutationsView = z.infer<typeof mutationsViewSchema>;
35
35
 
36
36
  const mutationsPropsSchema = z.object({
37
37
  lapisFilter: lapisFilterSchema,
38
38
  sequenceType: sequenceTypeSchema,
39
- views: viewSchema.array(),
39
+ views: mutationsViewSchema.array(),
40
40
  pageSize: z.union([z.boolean(), z.number()]),
41
41
  width: z.string(),
42
42
  height: z.string(),
@@ -95,7 +95,7 @@ const MutationsTabs: FunctionComponent<MutationTabsProps> = ({ mutationsData, or
95
95
 
96
96
  const filteredData = filterMutationsData(mutationsData, displayedSegments, displayedMutationTypes);
97
97
 
98
- const getTab = (view: View) => {
98
+ const getTab = (view: MutationsView) => {
99
99
  switch (view) {
100
100
  case 'table':
101
101
  return {