@genspectrum/dashboard-components 0.11.2 → 0.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,18 +1,41 @@
1
1
  import { type FunctionComponent } from 'preact';
2
2
 
3
- import type { AggregateData } from '../../query/queryAggregateData';
4
- import { AggregateTable } from '../aggregatedData/aggregate-table';
3
+ import type { EnhancedLocationsTableData } from '../../query/computeMapLocationData';
4
+ import { type AggregateData, compareAscending } from '../../query/queryAggregateData';
5
+ import { Table } from '../components/table';
6
+ import { formatProportion } from '../shared/table/formatProportion';
5
7
 
6
8
  type SequencesByLocationTableProps = {
7
- locationData: AggregateData;
9
+ tableData: AggregateData | EnhancedLocationsTableData;
8
10
  lapisLocationField: string;
9
11
  pageSize: boolean | number;
10
12
  };
11
13
 
12
14
  export const SequencesByLocationTable: FunctionComponent<SequencesByLocationTableProps> = ({
13
- locationData,
15
+ tableData,
14
16
  lapisLocationField,
15
17
  pageSize,
16
18
  }) => {
17
- return <AggregateTable data={locationData} fields={[lapisLocationField]} pageSize={pageSize} />;
19
+ const headers = [
20
+ {
21
+ name: lapisLocationField,
22
+ sort: {
23
+ compare: compareAscending,
24
+ },
25
+ },
26
+ {
27
+ name: 'count',
28
+ sort: true,
29
+ },
30
+ {
31
+ name: 'proportion',
32
+ sort: true,
33
+ formatter: (cell: number) => formatProportion(cell),
34
+ },
35
+ ...('isShownOnMap' in tableData[0]
36
+ ? [{ id: 'isShownOnMap', name: 'shown on map', sort: true, width: '20%' }]
37
+ : []),
38
+ ];
39
+
40
+ return <Table data={tableData} columns={headers} pageSize={pageSize} />;
18
41
  };
@@ -4,15 +4,22 @@ import z from 'zod';
4
4
 
5
5
  import { SequencesByLocationMap } from './sequences-by-location-map';
6
6
  import { SequencesByLocationTable } from './sequences-by-location-table';
7
- import { type AggregateData, queryAggregateData } from '../../query/queryAggregateData';
7
+ import {
8
+ type EnhancedLocationsTableData,
9
+ type MapLocationData,
10
+ MapLocationDataType,
11
+ } from '../../query/computeMapLocationData';
12
+ import { type AggregateData } from '../../query/queryAggregateData';
13
+ import { querySequencesByLocationData } from '../../query/querySequencesByLocationData';
8
14
  import { LapisUrlContext } from '../LapisUrlContext';
15
+ import { CsvDownloadButton } from '../components/csv-download-button';
9
16
  import { ErrorBoundary } from '../components/error-boundary';
10
17
  import { Fullscreen } from '../components/fullscreen';
11
18
  import Info, { InfoComponentCode, InfoHeadline1, InfoParagraph } from '../components/info';
12
19
  import { LoadingDisplay } from '../components/loading-display';
13
20
  import { ResizeContainer } from '../components/resize-container';
14
21
  import { useQuery } from '../useQuery';
15
- import { mapSourceSchema } from './useGeoJsonMap';
22
+ import { mapSourceSchema } from './loadMapSource';
16
23
  import { lapisFilterSchema, views } from '../../types';
17
24
  import Tabs from '../components/tabs';
18
25
 
@@ -49,7 +56,7 @@ export const SequencesByLocation: FunctionComponent<SequencesByLocationProps> =
49
56
  };
50
57
 
51
58
  const SequencesByLocationMapInner: FunctionComponent<SequencesByLocationProps> = (props) => {
52
- const { lapisFilter, lapisLocationField } = props;
59
+ const { lapisFilter, lapisLocationField, mapSource } = props;
53
60
 
54
61
  const lapis = useContext(LapisUrlContext);
55
62
  const {
@@ -57,8 +64,8 @@ const SequencesByLocationMapInner: FunctionComponent<SequencesByLocationProps> =
57
64
  error,
58
65
  isLoading: isLoadingLapisData,
59
66
  } = useQuery(
60
- async () => queryAggregateData(lapisFilter, [lapisLocationField], lapis),
61
- [lapisFilter, lapisLocationField, lapis],
67
+ async () => querySequencesByLocationData(lapisFilter, lapisLocationField, lapis, mapSource),
68
+ [lapisFilter, lapisLocationField, lapis, mapSource],
62
69
  );
63
70
 
64
71
  if (isLoadingLapisData) {
@@ -74,7 +81,7 @@ const SequencesByLocationMapInner: FunctionComponent<SequencesByLocationProps> =
74
81
 
75
82
  type SequencesByLocationMapTabsProps = {
76
83
  originalComponentProps: SequencesByLocationProps;
77
- data: AggregateData;
84
+ data: MapLocationData;
78
85
  };
79
86
 
80
87
  const SequencesByLocationMapTabs: FunctionComponent<SequencesByLocationMapTabsProps> = ({
@@ -83,16 +90,16 @@ const SequencesByLocationMapTabs: FunctionComponent<SequencesByLocationMapTabsPr
83
90
  }) => {
84
91
  const getTab = (view: SequencesByLocationMapView) => {
85
92
  switch (view) {
86
- case views.map:
87
- if (originalComponentProps.mapSource === undefined) {
93
+ case views.map: {
94
+ if (data.type !== MapLocationDataType.tableAndMapData) {
88
95
  throw new Error('mapSource is required when using the map view');
89
96
  }
97
+ const { type: _type, tableData: _tableData, ...dataForMap } = data;
90
98
  return {
91
99
  title: 'Map',
92
100
  content: (
93
101
  <SequencesByLocationMap
94
- locationData={data}
95
- mapSource={originalComponentProps.mapSource}
102
+ {...dataForMap}
96
103
  enableMapNavigation={originalComponentProps.enableMapNavigation}
97
104
  lapisLocationField={originalComponentProps.lapisLocationField}
98
105
  zoom={originalComponentProps.zoom}
@@ -102,12 +109,13 @@ const SequencesByLocationMapTabs: FunctionComponent<SequencesByLocationMapTabsPr
102
109
  />
103
110
  ),
104
111
  };
112
+ }
105
113
  case views.table:
106
114
  return {
107
115
  title: 'Table',
108
116
  content: (
109
117
  <SequencesByLocationTable
110
- locationData={data}
118
+ tableData={data.tableData}
111
119
  lapisLocationField={originalComponentProps.lapisLocationField}
112
120
  pageSize={originalComponentProps.pageSize}
113
121
  />
@@ -118,16 +126,27 @@ const SequencesByLocationMapTabs: FunctionComponent<SequencesByLocationMapTabsPr
118
126
 
119
127
  const tabs = originalComponentProps.views.map((view) => getTab(view));
120
128
 
121
- return <Tabs tabs={tabs} toolbar={<Toolbar originalComponentProps={originalComponentProps} />} />;
129
+ return (
130
+ <Tabs
131
+ tabs={tabs}
132
+ toolbar={<Toolbar originalComponentProps={originalComponentProps} tableData={data.tableData} />}
133
+ />
134
+ );
122
135
  };
123
136
 
124
137
  type ToolbarProps = {
125
138
  originalComponentProps: SequencesByLocationProps;
139
+ tableData: AggregateData | EnhancedLocationsTableData;
126
140
  };
127
141
 
128
- const Toolbar: FunctionComponent<ToolbarProps> = ({ originalComponentProps }) => {
142
+ const Toolbar: FunctionComponent<ToolbarProps> = ({ originalComponentProps, tableData }) => {
129
143
  return (
130
144
  <div class='flex flex-row'>
145
+ <CsvDownloadButton
146
+ className='mx-1 btn btn-xs'
147
+ getData={() => tableData}
148
+ filename='sequences_by_location.csv'
149
+ />
131
150
  <SequencesByLocationMapInfo originalComponentProps={originalComponentProps} />
132
151
  <Fullscreen />
133
152
  </div>
@@ -19,7 +19,7 @@ export function useQuery<Data>(fetchDataCallback: () => Promise<Data>, dependenc
19
19
  }
20
20
  };
21
21
 
22
- fetchData();
22
+ void fetchData();
23
23
  // eslint-disable-next-line react-hooks/exhaustive-deps
24
24
  }, [JSON.stringify(dependencies)]);
25
25
 
@@ -0,0 +1,103 @@
1
+ import type { FeatureCollection, GeometryObject } from 'geojson';
2
+ import { describe, expect, test } from 'vitest';
3
+
4
+ import { computeMapLocationData } from './computeMapLocationData';
5
+ import type { GeoJsonFeatureProperties } from '../preact/map/loadMapSource';
6
+
7
+ const lapisLocationField = 'locationField';
8
+
9
+ const country1 = 'country1';
10
+ const country2 = 'country2';
11
+
12
+ const locationData = [
13
+ {
14
+ [lapisLocationField]: country1,
15
+ count: 1,
16
+ proportion: 0.1,
17
+ },
18
+ {
19
+ [lapisLocationField]: country2,
20
+ count: 2,
21
+ proportion: 0.2,
22
+ },
23
+ {
24
+ [lapisLocationField]: null,
25
+ count: 3,
26
+ proportion: 0.3,
27
+ },
28
+ ];
29
+
30
+ const geometryObject = {
31
+ type: 'GeometryCollection',
32
+ geometries: [],
33
+ } satisfies GeometryObject;
34
+
35
+ const geojsonData = {
36
+ type: 'FeatureCollection',
37
+ features: [
38
+ {
39
+ type: 'Feature',
40
+ properties: {
41
+ name: country1,
42
+ },
43
+ geometry: geometryObject,
44
+ },
45
+ ],
46
+ } satisfies FeatureCollection<GeometryObject, GeoJsonFeatureProperties>;
47
+
48
+ describe('computeMapLocationData', () => {
49
+ test('should return tableDataOnly when geojsonData is undefined', () => {
50
+ const actual = computeMapLocationData(locationData, undefined, lapisLocationField);
51
+
52
+ expect(actual).to.deep.equal({
53
+ type: 'tableDataOnly',
54
+ tableData: locationData,
55
+ });
56
+ });
57
+
58
+ test('should return tableAndMapData when geojsonData is defined', () => {
59
+ const actual = computeMapLocationData(locationData, geojsonData, lapisLocationField);
60
+
61
+ expect(actual).to.deep.equal({
62
+ type: 'tableAndMapData',
63
+ locations: [
64
+ {
65
+ type: 'Feature',
66
+ properties: {
67
+ name: country1,
68
+ data: {
69
+ count: 1,
70
+ proportion: 0.1,
71
+ [lapisLocationField]: country1,
72
+ },
73
+ },
74
+ geometry: geometryObject,
75
+ },
76
+ ],
77
+ tableData: [
78
+ {
79
+ [lapisLocationField]: country1,
80
+ count: 1,
81
+ proportion: 0.1,
82
+ isShownOnMap: 'true',
83
+ },
84
+ {
85
+ [lapisLocationField]: country2,
86
+ count: 2,
87
+ proportion: 0.2,
88
+ isShownOnMap: 'false',
89
+ },
90
+ {
91
+ [lapisLocationField]: null,
92
+ count: 3,
93
+ proportion: 0.3,
94
+ isShownOnMap: 'false',
95
+ },
96
+ ],
97
+ totalCount: 6,
98
+ countOfMatchedLocationData: 1,
99
+ unmatchedLocations: [country2],
100
+ nullCount: 3,
101
+ });
102
+ });
103
+ });
@@ -0,0 +1,136 @@
1
+ import type { Feature, FeatureCollection, GeometryObject } from 'geojson';
2
+
3
+ import type { AggregateData } from './queryAggregateData';
4
+ import type { GeoJsonFeatureProperties } from '../preact/map/loadMapSource';
5
+
6
+ export type FeatureData = { proportion: number; count: number };
7
+
8
+ export type EnhancedGeoJsonFeatureProperties = GeoJsonFeatureProperties & {
9
+ data: FeatureData | null;
10
+ };
11
+
12
+ export type EnhancedLocationsTableData = (AggregateData[number] & { isShownOnMap: string })[];
13
+
14
+ export const MapLocationDataType = {
15
+ tableDataOnly: 'tableDataOnly',
16
+ tableAndMapData: 'tableAndMapData',
17
+ } as const;
18
+
19
+ export type MapLocationData =
20
+ | {
21
+ type: typeof MapLocationDataType.tableDataOnly;
22
+ tableData: AggregateData;
23
+ }
24
+ | {
25
+ type: typeof MapLocationDataType.tableAndMapData;
26
+ locations: Feature<GeometryObject, EnhancedGeoJsonFeatureProperties>[];
27
+ tableData: EnhancedLocationsTableData;
28
+ totalCount: number;
29
+ countOfMatchedLocationData: number;
30
+ unmatchedLocations: string[];
31
+ nullCount: number;
32
+ };
33
+
34
+ export function computeMapLocationData(
35
+ locationData: AggregateData,
36
+ geojsonData: FeatureCollection<GeometryObject, GeoJsonFeatureProperties> | undefined,
37
+ lapisLocationField: string,
38
+ ): MapLocationData {
39
+ if (geojsonData === undefined) {
40
+ return { type: MapLocationDataType.tableDataOnly, tableData: locationData };
41
+ }
42
+
43
+ const countAndProportionByCountry = buildLookupByLocationField(locationData, lapisLocationField);
44
+ const { locations, unmatchedLocations } = matchLocationDataAndGeoJsonFeatures(
45
+ geojsonData,
46
+ countAndProportionByCountry,
47
+ lapisLocationField,
48
+ );
49
+
50
+ const totalCount = locationData.map((value) => value.count).reduce((sum, b) => sum + b, 0);
51
+ const countOfMatchedLocationData = locations
52
+ .map((location) => location.properties.data?.count ?? 0)
53
+ .reduce((sum, b) => sum + b, 0);
54
+ const nullCount = locationData.find((row) => row[lapisLocationField] === null)?.count ?? 0;
55
+
56
+ const tableData = getSequencesByLocationTableData(locationData, unmatchedLocations, lapisLocationField);
57
+
58
+ return {
59
+ type: MapLocationDataType.tableAndMapData,
60
+ locations,
61
+ tableData,
62
+ totalCount,
63
+ countOfMatchedLocationData,
64
+ unmatchedLocations,
65
+ nullCount,
66
+ };
67
+ }
68
+
69
+ function buildLookupByLocationField(locationData: AggregateData, lapisLocationField: string) {
70
+ return new Map<string, FeatureData>(
71
+ locationData
72
+ .filter((row) => typeof row[lapisLocationField] === 'string')
73
+ .map((row) => [row[lapisLocationField] as string, row]),
74
+ );
75
+ }
76
+
77
+ function matchLocationDataAndGeoJsonFeatures(
78
+ geojsonData: FeatureCollection<GeometryObject, GeoJsonFeatureProperties>,
79
+ countAndProportionByCountry: Map<string, FeatureData>,
80
+ lapisLocationField: string,
81
+ ) {
82
+ const matchedLocations: string[] = [];
83
+
84
+ const locations: Feature<GeometryObject, EnhancedGeoJsonFeatureProperties>[] = geojsonData.features.map(
85
+ (feature) => {
86
+ const name = feature?.properties?.name;
87
+ if (typeof name !== 'string') {
88
+ throw new Error(
89
+ `GeoJSON feature with id '${feature.id}' does not have 'properties.name' of type string, was: '${name}'`,
90
+ );
91
+ }
92
+
93
+ const data = countAndProportionByCountry.get(name) ?? null;
94
+ if (data !== null) {
95
+ matchedLocations.push(name);
96
+ }
97
+ return {
98
+ ...feature,
99
+ properties: {
100
+ ...feature.properties,
101
+ data,
102
+ },
103
+ };
104
+ },
105
+ );
106
+
107
+ const unmatchedLocations = [...countAndProportionByCountry.keys()].filter(
108
+ (name) => !matchedLocations.includes(name),
109
+ );
110
+ if (unmatchedLocations.length > 0) {
111
+ 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(', ')}`;
112
+ console.warn(unmatchedLocationsWarning); // eslint-disable-line no-console -- We should give some feedback about unmatched location data.
113
+ }
114
+
115
+ return { locations, unmatchedLocations };
116
+ }
117
+
118
+ export function getSequencesByLocationTableData(
119
+ locationData: AggregateData,
120
+ unmatchedLocations: string[],
121
+ lapisLocationField: string,
122
+ ): EnhancedLocationsTableData {
123
+ return locationData.map((row) => ({
124
+ ...row,
125
+ isShownOnMap: `${isShownOnMap(row, unmatchedLocations, lapisLocationField)}`,
126
+ }));
127
+ }
128
+
129
+ function isShownOnMap(row: AggregateData[number], unmatchedLocations: string[], lapisLocationField: string) {
130
+ const locationValue = row[lapisLocationField];
131
+ if (locationValue === null) {
132
+ return false;
133
+ }
134
+
135
+ return !unmatchedLocations.includes(locationValue as string);
136
+ }
@@ -0,0 +1,18 @@
1
+ import { computeMapLocationData } from './computeMapLocationData';
2
+ import { queryAggregateData } from './queryAggregateData';
3
+ import { loadMapSource, type MapSource } from '../preact/map/loadMapSource';
4
+ import type { LapisFilter } from '../types';
5
+
6
+ export async function querySequencesByLocationData(
7
+ lapisFilter: LapisFilter,
8
+ lapisLocationField: string,
9
+ lapis: string,
10
+ mapSource: MapSource | undefined,
11
+ ) {
12
+ const [locationData, geojsonData] = await Promise.all([
13
+ queryAggregateData(lapisFilter, [lapisLocationField], lapis),
14
+ mapSource !== undefined ? loadMapSource(mapSource) : undefined,
15
+ ]);
16
+
17
+ return computeMapLocationData(locationData, geojsonData, lapisLocationField);
18
+ }
@@ -27,7 +27,7 @@ export type {
27
27
  RelativeGrowthAdvantageProps,
28
28
  } from './preact/relativeGrowthAdvantage/relative-growth-advantage';
29
29
  export type { StatisticsProps } from './preact/statistic/statistics';
30
- export type { MapSource } from './preact/map/useGeoJsonMap';
30
+ export type { MapSource } from './preact/map/loadMapSource';
31
31
 
32
32
  export type { ConfidenceIntervalMethod } from './preact/shared/charts/confideceInterval';
33
33
 
@@ -1,3 +1,4 @@
1
+ import { expect, userEvent, waitFor } from '@storybook/test';
1
2
  import { type Meta, type StoryObj } from '@storybook/web-components';
2
3
  import { html } from 'lit';
3
4
 
@@ -9,6 +10,7 @@ import aggregatedWorld from '../../preact/map/__mockData__/aggregatedWorld.json'
9
10
  import mapOfGermany from '../../preact/map/__mockData__/germanyMap.json';
10
11
  import worldAtlas from '../../preact/map/__mockData__/worldAtlas.json';
11
12
  import { type SequencesByLocationProps } from '../../preact/map/sequences-by-location';
13
+ import { withinShadowRoot } from '../withinShadowRoot.story';
12
14
 
13
15
  import './gs-sequences-by-location';
14
16
  import '../app';
@@ -188,6 +190,21 @@ export const Germany: StoryObj<SequencesByLocationProps> = {
188
190
  },
189
191
  };
190
192
 
193
+ export const GermanyOnTableTab: StoryObj<SequencesByLocationProps> = {
194
+ ...Germany,
195
+ play: async ({ canvasElement, step }) => {
196
+ const canvas = await withinShadowRoot(canvasElement, 'gs-sequences-by-location');
197
+
198
+ await waitFor(() => expect(canvas.getByRole('button', { name: 'Table' })).toBeInTheDocument());
199
+ await userEvent.click(canvas.getByRole('button', { name: 'Table' }));
200
+
201
+ await step('Sort by division', async () => {
202
+ await waitFor(() => expect(canvas.getByText('division')).toBeInTheDocument());
203
+ await userEvent.click(canvas.getByText('division'));
204
+ });
205
+ },
206
+ };
207
+
191
208
  export const GermanyTableOnly: StoryObj<SequencesByLocationProps> = {
192
209
  render: (args) => html`
193
210
  <gs-app lapis="${LAPIS_URL}">