@genspectrum/dashboard-components 0.6.3 → 0.6.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.
@@ -501,6 +501,11 @@ export declare class MutationsComponent extends PreactLitAdapterWithGridJsStyles
501
501
  *
502
502
  * The grid view shows the proportion for each mutation over date ranges.
503
503
  *
504
+ * The grid will show at max 100 rows and 200 columns for browser performance reasons.
505
+ * More data might make the browser unresponsive.
506
+ * If the numbers are exceeded, an error message will be shown.
507
+ * In both cases, the `lapisFilter` should be narrowed down to reduce the number of mutations or date ranges.
508
+ * The number of date ranges can also be reduced by selecting a larger granularity (months instead of weeks).
504
509
  */
505
510
  export declare class MutationsOverTimeComponent extends PreactLitAdapterWithGridJsStyles {
506
511
  /**
@@ -958,14 +963,14 @@ declare global {
958
963
 
959
964
  declare global {
960
965
  interface HTMLElementTagNameMap {
961
- 'gs-mutations-component': MutationsComponent;
966
+ 'gs-prevalence-over-time': PrevalenceOverTimeComponent;
962
967
  }
963
968
  }
964
969
 
965
970
 
966
971
  declare global {
967
972
  interface HTMLElementTagNameMap {
968
- 'gs-prevalence-over-time': PrevalenceOverTimeComponent;
973
+ 'gs-mutations-component': MutationsComponent;
969
974
  }
970
975
  }
971
976
 
package/dist/style.css CHANGED
@@ -3150,6 +3150,9 @@ input.tab:checked + .tab-content,
3150
3150
  --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
3151
3151
  --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color);
3152
3152
  box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
3153
+ }
3154
+ .peer:hover ~ .peer-hover\:block {
3155
+ display: block;
3153
3156
  }.flatpickr-calendar{background:transparent;opacity:0;display:none;text-align:center;visibility:hidden;padding:0;-webkit-animation:none;animation:none;direction:ltr;border:0;font-size:14px;line-height:24px;border-radius:5px;position:absolute;width:307.875px;-webkit-box-sizing:border-box;box-sizing:border-box;-ms-touch-action:manipulation;touch-action:manipulation;background:#fff;-webkit-box-shadow:1px 0 0 #e6e6e6,-1px 0 0 #e6e6e6,0 1px 0 #e6e6e6,0 -1px 0 #e6e6e6,0 3px 13px rgba(0,0,0,0.08);box-shadow:1px 0 0 #e6e6e6,-1px 0 0 #e6e6e6,0 1px 0 #e6e6e6,0 -1px 0 #e6e6e6,0 3px 13px rgba(0,0,0,0.08)}.flatpickr-calendar.open,.flatpickr-calendar.inline{opacity:1;max-height:640px;visibility:visible}.flatpickr-calendar.open{display:inline-block;z-index:99999}.flatpickr-calendar.animate.open{-webkit-animation:fpFadeInDown 300ms cubic-bezier(.23,1,.32,1);animation:fpFadeInDown 300ms cubic-bezier(.23,1,.32,1)}.flatpickr-calendar.inline{display:block;position:relative;top:2px}.flatpickr-calendar.static{position:absolute;top:calc(100% + 2px)}.flatpickr-calendar.static.open{z-index:999;display:block}.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+1) .flatpickr-day.inRange:nth-child(7n+7){-webkit-box-shadow:none !important;box-shadow:none !important}.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+2) .flatpickr-day.inRange:nth-child(7n+1){-webkit-box-shadow:-2px 0 0 #e6e6e6,5px 0 0 #e6e6e6;box-shadow:-2px 0 0 #e6e6e6,5px 0 0 #e6e6e6}.flatpickr-calendar .hasWeeks .dayContainer,.flatpickr-calendar .hasTime .dayContainer{border-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.flatpickr-calendar .hasWeeks .dayContainer{border-left:0}.flatpickr-calendar.hasTime .flatpickr-time{height:40px;border-top:1px solid #e6e6e6}.flatpickr-calendar.noCalendar.hasTime .flatpickr-time{height:auto}.flatpickr-calendar:before,.flatpickr-calendar:after{position:absolute;display:block;pointer-events:none;border:solid transparent;content:'';height:0;width:0;left:22px}.flatpickr-calendar.rightMost:before,.flatpickr-calendar.arrowRight:before,.flatpickr-calendar.rightMost:after,.flatpickr-calendar.arrowRight:after{left:auto;right:22px}.flatpickr-calendar.arrowCenter:before,.flatpickr-calendar.arrowCenter:after{left:50%;right:50%}.flatpickr-calendar:before{border-width:5px;margin:0 -5px}.flatpickr-calendar:after{border-width:4px;margin:0 -4px}.flatpickr-calendar.arrowTop:before,.flatpickr-calendar.arrowTop:after{bottom:100%}.flatpickr-calendar.arrowTop:before{border-bottom-color:#e6e6e6}.flatpickr-calendar.arrowTop:after{border-bottom-color:#fff}.flatpickr-calendar.arrowBottom:before,.flatpickr-calendar.arrowBottom:after{top:100%}.flatpickr-calendar.arrowBottom:before{border-top-color:#e6e6e6}.flatpickr-calendar.arrowBottom:after{border-top-color:#fff}.flatpickr-calendar:focus{outline:0}.flatpickr-wrapper{position:relative;display:inline-block}.flatpickr-months{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex}.flatpickr-months .flatpickr-month{background:transparent;color:rgba(0,0,0,0.9);fill:rgba(0,0,0,0.9);height:34px;line-height:1;text-align:center;position:relative;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;overflow:hidden;-webkit-box-flex:1;-webkit-flex:1;-ms-flex:1;flex:1}.flatpickr-months .flatpickr-prev-month,.flatpickr-months .flatpickr-next-month{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;text-decoration:none;cursor:pointer;position:absolute;top:0;height:34px;padding:10px;z-index:3;color:rgba(0,0,0,0.9);fill:rgba(0,0,0,0.9)}.flatpickr-months .flatpickr-prev-month.flatpickr-disabled,.flatpickr-months .flatpickr-next-month.flatpickr-disabled{display:none}.flatpickr-months .flatpickr-prev-month i,.flatpickr-months .flatpickr-next-month i{position:relative}.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month,.flatpickr-months .flatpickr-next-month.flatpickr-prev-month{/*
3154
3157
  /*rtl:begin:ignore*/left:0/*
3155
3158
  /*rtl:end:ignore*/}/*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genspectrum/dashboard-components",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "GenSpectrum web components for building dashboards",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-only",
@@ -0,0 +1,54 @@
1
+ import { type Meta, type StoryObj } from '@storybook/preact';
2
+ import { expect, waitFor, within } from '@storybook/test';
3
+
4
+ import Tooltip, { type TooltipProps } from './tooltip';
5
+
6
+ const meta: Meta<TooltipProps> = {
7
+ title: 'Component/Tooltip',
8
+ component: Tooltip,
9
+ parameters: { fetchMock: {} },
10
+ };
11
+
12
+ export default meta;
13
+
14
+ const tooltipContent = 'This is some content.';
15
+
16
+ export const TooltipStory: StoryObj<TooltipProps> = {
17
+ render: (args) => (
18
+ <div class='flex justify-center px-4 py-16'>
19
+ <Tooltip {...args}>
20
+ <div>Hover me</div>
21
+ </Tooltip>
22
+ </div>
23
+ ),
24
+ args: {
25
+ content: tooltipContent,
26
+ },
27
+ };
28
+
29
+ export const RendersStringContent: StoryObj<TooltipProps> = {
30
+ ...TooltipStory,
31
+ play: async ({ canvasElement }) => {
32
+ const canvas = within(canvasElement);
33
+ const tooltipBase = canvas.getByText('Hover me');
34
+
35
+ await waitFor(() => expect(tooltipBase).toBeInTheDocument());
36
+
37
+ await waitFor(() => expect(canvas.queryByText(tooltipContent, { exact: false })).toBeInTheDocument());
38
+ },
39
+ };
40
+
41
+ export const RendersComponentConent: StoryObj<TooltipProps> = {
42
+ ...TooltipStory,
43
+ args: {
44
+ content: <div>{tooltipContent}</div>,
45
+ },
46
+ play: async ({ canvasElement }) => {
47
+ const canvas = within(canvasElement);
48
+ const tooltipBase = canvas.getByText('Hover me');
49
+
50
+ await waitFor(() => expect(tooltipBase).toBeInTheDocument());
51
+
52
+ await waitFor(() => expect(canvas.queryByText(tooltipContent, { exact: false })).toBeInTheDocument());
53
+ },
54
+ };
@@ -0,0 +1,31 @@
1
+ import { flip, offset, shift } from '@floating-ui/dom';
2
+ import { type FunctionComponent } from 'preact';
3
+ import { useRef } from 'preact/hooks';
4
+ import { type JSXInternal } from 'preact/src/jsx';
5
+
6
+ import { dropdownClass } from './dropdown';
7
+ import { useFloatingUi } from '../shared/floating-ui/hooks';
8
+
9
+ export type TooltipProps = {
10
+ content: string | JSXInternal.Element;
11
+ };
12
+
13
+ const Tooltip: FunctionComponent<TooltipProps> = ({ children, content }) => {
14
+ const referenceRef = useRef<HTMLDivElement>(null);
15
+ const floatingRef = useRef<HTMLDivElement>(null);
16
+
17
+ useFloatingUi(referenceRef, floatingRef, [offset(5), shift(), flip()]);
18
+
19
+ return (
20
+ <div className='relative'>
21
+ <div className='peer' ref={referenceRef}>
22
+ {children}
23
+ </div>
24
+ <div ref={floatingRef} className={`${dropdownClass} hidden peer-hover:block`}>
25
+ {content}
26
+ </div>
27
+ </div>
28
+ );
29
+ };
30
+
31
+ export default Tooltip;
@@ -12,8 +12,14 @@ describe('getFilteredMutationOverTimeData', () => {
12
12
  it('should filter by displayed segments', () => {
13
13
  const data = new Map2d<Substitution | Deletion, Temporal, MutationOverTimeMutationValue>();
14
14
 
15
- data.set(new Substitution('someSegment', 'A', 'T', 123), yearMonthDay('2021-01-01'), 1);
16
- data.set(new Substitution('someOtherSegment', 'A', 'T', 123), yearMonthDay('2021-01-01'), 2);
15
+ data.set(new Substitution('someSegment', 'A', 'T', 123), yearMonthDay('2021-01-01'), {
16
+ count: 1,
17
+ proportion: 0.1,
18
+ });
19
+ data.set(new Substitution('someOtherSegment', 'A', 'T', 123), yearMonthDay('2021-01-01'), {
20
+ count: 2,
21
+ proportion: 0.2,
22
+ });
17
23
 
18
24
  filterDisplayedSegments(
19
25
  [
@@ -32,8 +38,14 @@ describe('getFilteredMutationOverTimeData', () => {
32
38
  it('should filter by mutation types', () => {
33
39
  const data = new Map2d<Substitution | Deletion, Temporal, MutationOverTimeMutationValue>();
34
40
 
35
- data.set(new Substitution('someSegment', 'A', 'T', 123), yearMonthDay('2021-01-01'), 1);
36
- data.set(new Deletion('someOtherSegment', 'A', 123), yearMonthDay('2021-01-01'), 2);
41
+ data.set(new Substitution('someSegment', 'A', 'T', 123), yearMonthDay('2021-01-01'), {
42
+ count: 1,
43
+ proportion: 0.1,
44
+ });
45
+ data.set(new Deletion('someOtherSegment', 'A', 123), yearMonthDay('2021-01-01'), {
46
+ count: 2,
47
+ proportion: 0.2,
48
+ });
37
49
 
38
50
  filterMutationTypes(
39
51
  [
@@ -52,8 +64,8 @@ describe('getFilteredMutationOverTimeData', () => {
52
64
  it('should filter by proportion', () => {
53
65
  const data = new Map2d<Substitution | Deletion, Temporal, MutationOverTimeMutationValue>();
54
66
 
55
- const belowFilter = 0.1;
56
- const aboveFilter = 0.99;
67
+ const belowFilter = { count: 1, proportion: 0.1 };
68
+ const aboveFilter = { count: 99, proportion: 0.99 };
57
69
  const proportionInterval = { min: 0.2, max: 0.9 };
58
70
 
59
71
  const someSubstitution = new Substitution('someSegment', 'A', 'T', 123);
@@ -62,15 +74,15 @@ describe('getFilteredMutationOverTimeData', () => {
62
74
 
63
75
  filterProportion(data, proportionInterval);
64
76
 
65
- expect(data.getAsArray(0).length).to.equal(0);
77
+ expect(data.getAsArray({ count: 0, proportion: 0 }).length).to.equal(0);
66
78
  });
67
79
 
68
80
  it('should not filter if one proportion is within the interval', () => {
69
81
  const data = new Map2d<Substitution | Deletion, Temporal, MutationOverTimeMutationValue>();
70
82
 
71
- const belowFilter = 0.1;
72
- const aboveFilter = 0.99;
73
- const inFilter = 0.5;
83
+ const belowFilter = { count: 1, proportion: 0.1 };
84
+ const aboveFilter = { count: 99, proportion: 0.99 };
85
+ const inFilter = { count: 5, proportion: 0.5 };
74
86
  const proportionInterval = { min: 0.2, max: 0.9 };
75
87
 
76
88
  const someSubstitution = new Substitution('someSegment', 'A', 'T', 123);
@@ -80,7 +92,7 @@ describe('getFilteredMutationOverTimeData', () => {
80
92
 
81
93
  filterProportion(data, proportionInterval);
82
94
 
83
- expect(data.getRow(someSubstitution, 0).length).to.equal(3);
95
+ expect(data.getRow(someSubstitution, { count: 0, proportion: 0 }).length).to.equal(3);
84
96
  });
85
97
  });
86
98
  });
@@ -54,8 +54,12 @@ export function filterProportion(
54
54
  },
55
55
  ) {
56
56
  data.getFirstAxisKeys().forEach((mutation) => {
57
- const row = data.getRow(mutation, 0);
58
- if (!row.some((value) => value >= proportionInterval.min && value <= proportionInterval.max)) {
57
+ const row = data.getRow(mutation, { count: 0, proportion: 0 });
58
+ if (
59
+ !row.some(
60
+ (value) => value.proportion >= proportionInterval.min && value.proportion <= proportionInterval.max,
61
+ )
62
+ ) {
59
63
  data.deleteRow(mutation);
60
64
  }
61
65
  });
@@ -5,7 +5,9 @@ import {
5
5
  type MutationOverTimeMutationValue,
6
6
  } from '../../query/queryMutationsOverTime';
7
7
  import { type Deletion, type Substitution } from '../../utils/mutations';
8
- import { compareTemporal, type Temporal } from '../../utils/temporal';
8
+ import { compareTemporal, type Temporal, YearMonthDay } from '../../utils/temporal';
9
+ import { UserFacingError } from '../components/error-display';
10
+ import Tooltip from '../components/tooltip';
9
11
  import { singleGraphColorRGBByName } from '../shared/charts/colors';
10
12
  import { formatProportion } from '../shared/table/formatProportion';
11
13
 
@@ -13,8 +15,18 @@ export interface MutationsOverTimeGridProps {
13
15
  data: MutationOverTimeDataGroupedByMutation;
14
16
  }
15
17
 
18
+ const MAX_NUMBER_OF_GRID_ROWS = 100;
19
+
16
20
  const MutationsOverTimeGrid: FunctionComponent<MutationsOverTimeGridProps> = ({ data }) => {
17
21
  const mutations = data.getFirstAxisKeys();
22
+ if (mutations.length > MAX_NUMBER_OF_GRID_ROWS) {
23
+ throw new UserFacingError(
24
+ 'Too many mutations',
25
+ `The dataset contains ${mutations.length} mutations. ` +
26
+ `Please adapt the filters to reduce the number to below ${MAX_NUMBER_OF_GRID_ROWS}.`,
27
+ );
28
+ }
29
+
18
30
  const dates = data.getSecondAxisKeys().sort((a, b) => compareTemporal(a, b));
19
31
 
20
32
  return (
@@ -35,7 +47,7 @@ const MutationsOverTimeGrid: FunctionComponent<MutationsOverTimeGridProps> = ({
35
47
  <MutationCell mutation={mutation} />
36
48
  </div>
37
49
  {dates.map((date, j) => {
38
- const value = data.get(mutation, date) ?? 0;
50
+ const value = data.get(mutation, date) ?? { proportion: 0, count: 0 };
39
51
  return (
40
52
  <div
41
53
  style={{ gridRowStart: i + 1, gridColumnStart: j + 2 }}
@@ -56,22 +68,45 @@ const ProportionCell: FunctionComponent<{
56
68
  value: MutationOverTimeMutationValue;
57
69
  date: Temporal;
58
70
  mutation: Substitution | Deletion;
59
- }> = ({ value }) => {
60
- // TODO(#353): Add tooltip with date, mutation and proportion
71
+ }> = ({ value, mutation, date }) => {
72
+ const tooltipContent = (
73
+ <div>
74
+ <p>
75
+ <span className='font-bold'>{date.englishName()}</span> ({timeIntervalDisplay(date)})
76
+ </p>
77
+ <p>{mutation.code}</p>
78
+ <p>Proportion: {formatProportion(value.proportion)}</p>
79
+ <p>Count: {value.count}</p>
80
+ </div>
81
+ );
82
+
61
83
  return (
62
84
  <>
63
85
  <div className={'py-1'}>
64
- <div
65
- style={{ backgroundColor: backgroundColor(value), color: textColor(value) }}
66
- className='text-center hover:font-bold text-xs'
67
- >
68
- {formatProportion(value, 0)}
69
- </div>
86
+ <Tooltip content={tooltipContent}>
87
+ <div
88
+ style={{
89
+ backgroundColor: backgroundColor(value.proportion),
90
+ color: textColor(value.proportion),
91
+ }}
92
+ className='text-center hover:font-bold text-xs'
93
+ >
94
+ {formatProportion(value.proportion, 0)}
95
+ </div>
96
+ </Tooltip>
70
97
  </div>
71
98
  </>
72
99
  );
73
100
  };
74
101
 
102
+ const timeIntervalDisplay = (date: Temporal) => {
103
+ if (date instanceof YearMonthDay) {
104
+ return date.toString();
105
+ }
106
+
107
+ return `${date.firstDay.toString()} - ${date.lastDay.toString()}`;
108
+ };
109
+
75
110
  const backgroundColor = (proportion: number) => {
76
111
  // TODO(#353): Make minAlpha and maxAlpha configurable
77
112
  const minAlpha = 0.0;
@@ -27,7 +27,7 @@ describe('queryMutationsOverTime', () => {
27
27
  dateFieldTo: '2023-01-01',
28
28
  minProportion: 0.001,
29
29
  },
30
- response: { data: [getSomeTestMutation(0.1), getSomeOtherTestMutation(0.4)] },
30
+ response: { data: [getSomeTestMutation(0.1, 1), getSomeOtherTestMutation(0.4, 4)] },
31
31
  },
32
32
  {
33
33
  body: {
@@ -36,7 +36,7 @@ describe('queryMutationsOverTime', () => {
36
36
  dateFieldTo: '2023-01-02',
37
37
  minProportion: 0.001,
38
38
  },
39
- response: { data: [getSomeTestMutation(0.2)] },
39
+ response: { data: [getSomeTestMutation(0.2, 2)] },
40
40
  },
41
41
  {
42
42
  body: {
@@ -45,7 +45,7 @@ describe('queryMutationsOverTime', () => {
45
45
  dateFieldTo: '2023-01-03',
46
46
  minProportion: 0.001,
47
47
  },
48
- response: { data: [getSomeTestMutation(0.3)] },
48
+ response: { data: [getSomeTestMutation(0.3, 3)] },
49
49
  },
50
50
  ],
51
51
  'nucleotide',
@@ -53,9 +53,17 @@ describe('queryMutationsOverTime', () => {
53
53
 
54
54
  const result = await queryMutationsOverTimeData(lapisFilter, 'nucleotide', DUMMY_LAPIS_URL, dateField, 'day');
55
55
 
56
- expect(result.getAsArray(0)).to.deep.equal([
57
- [0.1, 0.2, 0.3],
58
- [0.4, 0, 0],
56
+ expect(result.getAsArray({ count: 0, proportion: 0 })).to.deep.equal([
57
+ [
58
+ { proportion: 0.1, count: 1 },
59
+ { proportion: 0.2, count: 2 },
60
+ { proportion: 0.3, count: 3 },
61
+ ],
62
+ [
63
+ { proportion: 0.4, count: 4 },
64
+ { proportion: 0, count: 0 },
65
+ { proportion: 0, count: 0 },
66
+ ],
59
67
  ]);
60
68
 
61
69
  const sequences = result.getFirstAxisKeys();
@@ -91,7 +99,7 @@ describe('queryMutationsOverTime', () => {
91
99
  dateFieldTo: '2023-01-01',
92
100
  minProportion: 0.001,
93
101
  },
94
- response: { data: [getSomeTestMutation(0.1), getSomeOtherTestMutation(0.4)] },
102
+ response: { data: [getSomeTestMutation(0.1, 1), getSomeOtherTestMutation(0.4, 4)] },
95
103
  },
96
104
  {
97
105
  body: {
@@ -109,7 +117,7 @@ describe('queryMutationsOverTime', () => {
109
117
  dateFieldTo: '2023-01-03',
110
118
  minProportion: 0.001,
111
119
  },
112
- response: { data: [getSomeTestMutation(0.3)] },
120
+ response: { data: [getSomeTestMutation(0.3, 3)] },
113
121
  },
114
122
  ],
115
123
  'nucleotide',
@@ -117,9 +125,17 @@ describe('queryMutationsOverTime', () => {
117
125
 
118
126
  const result = await queryMutationsOverTimeData(lapisFilter, 'nucleotide', DUMMY_LAPIS_URL, dateField, 'day');
119
127
 
120
- expect(result.getAsArray(0)).to.deep.equal([
121
- [0.1, 0.3, 0],
122
- [0.4, 0, 0],
128
+ expect(result.getAsArray({ count: 0, proportion: 0 })).to.deep.equal([
129
+ [
130
+ { proportion: 0.1, count: 1 },
131
+ { proportion: 0.3, count: 3 },
132
+ { proportion: 0, count: 0 },
133
+ ],
134
+ [
135
+ { proportion: 0.4, count: 4 },
136
+ { proportion: 0, count: 0 },
137
+ { proportion: 0, count: 0 },
138
+ ],
123
139
  ]);
124
140
 
125
141
  const sequences = result.getFirstAxisKeys();
@@ -181,7 +197,7 @@ describe('queryMutationsOverTime', () => {
181
197
 
182
198
  const result = await queryMutationsOverTimeData(lapisFilter, 'nucleotide', DUMMY_LAPIS_URL, dateField, 'day');
183
199
 
184
- expect(result.getAsArray(0)).to.deep.equal([]);
200
+ expect(result.getAsArray({ count: 0, proportion: 0 })).to.deep.equal([]);
185
201
  expect(result.getFirstAxisKeys()).to.deep.equal([]);
186
202
  expect(result.getSecondAxisKeys()).to.deep.equal([]);
187
203
  });
@@ -209,7 +225,7 @@ describe('queryMutationsOverTime', () => {
209
225
  dateFieldTo: '2023-01-02',
210
226
  minProportion: 0.001,
211
227
  },
212
- response: { data: [getSomeTestMutation(0.2)] },
228
+ response: { data: [getSomeTestMutation(0.2, 2)] },
213
229
  },
214
230
  {
215
231
  body: {
@@ -218,7 +234,7 @@ describe('queryMutationsOverTime', () => {
218
234
  dateFieldTo: '2023-01-03',
219
235
  minProportion: 0.001,
220
236
  },
221
- response: { data: [getSomeTestMutation(0.3)] },
237
+ response: { data: [getSomeTestMutation(0.3, 3)] },
222
238
  },
223
239
  ],
224
240
  'nucleotide',
@@ -226,7 +242,12 @@ describe('queryMutationsOverTime', () => {
226
242
 
227
243
  const result = await queryMutationsOverTimeData(lapisFilter, 'nucleotide', DUMMY_LAPIS_URL, dateField, 'day');
228
244
 
229
- expect(result.getAsArray(0)).to.deep.equal([[0.2, 0.3]]);
245
+ expect(result.getAsArray({ count: 0, proportion: 0 })).to.deep.equal([
246
+ [
247
+ { proportion: 0.2, count: 2 },
248
+ { proportion: 0.3, count: 3 },
249
+ ],
250
+ ]);
230
251
 
231
252
  const sequences = result.getFirstAxisKeys();
232
253
  expect(sequences[0].code).toBe('sequenceName:A123T');
@@ -259,7 +280,7 @@ describe('queryMutationsOverTime', () => {
259
280
  dateFieldTo: '2023-01-01',
260
281
  minProportion: 0.001,
261
282
  },
262
- response: { data: [getSomeTestMutation(0.1)] },
283
+ response: { data: [getSomeTestMutation(0.1, 1)] },
263
284
  },
264
285
  {
265
286
  body: {
@@ -268,7 +289,7 @@ describe('queryMutationsOverTime', () => {
268
289
  dateFieldTo: '2023-01-02',
269
290
  minProportion: 0.001,
270
291
  },
271
- response: { data: [getSomeTestMutation(0.2)] },
292
+ response: { data: [getSomeTestMutation(0.2, 2)] },
272
293
  },
273
294
  ],
274
295
  'nucleotide',
@@ -276,7 +297,12 @@ describe('queryMutationsOverTime', () => {
276
297
 
277
298
  const result = await queryMutationsOverTimeData(lapisFilter, 'nucleotide', DUMMY_LAPIS_URL, dateField, 'day');
278
299
 
279
- expect(result.getAsArray(0)).to.deep.equal([[0.1, 0.2]]);
300
+ expect(result.getAsArray({ count: 0, proportion: 0 })).to.deep.equal([
301
+ [
302
+ { proportion: 0.1, count: 1 },
303
+ { proportion: 0.2, count: 2 },
304
+ ],
305
+ ]);
280
306
 
281
307
  const sequences = result.getFirstAxisKeys();
282
308
  expect(sequences[0].code).toBe('sequenceName:A123T');
@@ -309,7 +335,7 @@ describe('queryMutationsOverTime', () => {
309
335
  dateFieldTo: '2023-01-02',
310
336
  minProportion: 0.001,
311
337
  },
312
- response: { data: [getSomeTestMutation(0.2)] },
338
+ response: { data: [getSomeTestMutation(0.2, 2)] },
313
339
  },
314
340
  ],
315
341
  'nucleotide',
@@ -317,7 +343,7 @@ describe('queryMutationsOverTime', () => {
317
343
 
318
344
  const result = await queryMutationsOverTimeData(lapisFilter, 'nucleotide', DUMMY_LAPIS_URL, dateField, 'day');
319
345
 
320
- expect(result.getAsArray(0)).to.deep.equal([[0.2]]);
346
+ expect(result.getAsArray({ count: 0, proportion: 0 })).to.deep.equal([[{ proportion: 0.2, count: 2 }]]);
321
347
 
322
348
  const sequences = result.getFirstAxisKeys();
323
349
  expect(sequences[0].code).toBe('sequenceName:A123T');
@@ -326,11 +352,11 @@ describe('queryMutationsOverTime', () => {
326
352
  expect(dates[0].toString()).toBe('2023-01-02');
327
353
  });
328
354
 
329
- function getSomeTestMutation(proportion: number) {
355
+ function getSomeTestMutation(proportion: number, count: number) {
330
356
  return {
331
357
  mutation: 'sequenceName:A123T',
332
358
  proportion,
333
- count: 1,
359
+ count,
334
360
  sequenceName: 'sequenceName',
335
361
  mutationFrom: 'A',
336
362
  mutationTo: 'T',
@@ -338,11 +364,11 @@ describe('queryMutationsOverTime', () => {
338
364
  };
339
365
  }
340
366
 
341
- function getSomeOtherTestMutation(proportion: number) {
367
+ function getSomeOtherTestMutation(proportion: number, count: number) {
342
368
  return {
343
369
  mutation: 'otherSequenceName:A123T',
344
370
  proportion,
345
- count: 1,
371
+ count,
346
372
  sequenceName: 'otherSequenceName',
347
373
  mutationFrom: 'G',
348
374
  mutationTo: 'C',
@@ -5,6 +5,7 @@ import { GroupByAndSumOperator } from '../operator/GroupByAndSumOperator';
5
5
  import { MapOperator } from '../operator/MapOperator';
6
6
  import { RenameFieldOperator } from '../operator/RenameFieldOperator';
7
7
  import { SortOperator } from '../operator/SortOperator';
8
+ import { UserFacingError } from '../preact/components/error-display';
8
9
  import {
9
10
  type LapisFilter,
10
11
  type SequenceType,
@@ -26,13 +27,15 @@ export type MutationOverTimeData = {
26
27
  mutations: SubstitutionOrDeletionEntry[];
27
28
  };
28
29
 
29
- export type MutationOverTimeMutationValue = number;
30
+ export type MutationOverTimeMutationValue = { proportion: number; count: number };
30
31
  export type MutationOverTimeDataGroupedByMutation = Map2d<
31
32
  Substitution | Deletion,
32
33
  Temporal,
33
34
  MutationOverTimeMutationValue
34
35
  >;
35
36
 
37
+ const MAX_NUMBER_OF_GRID_COLUMNS = 200;
38
+
36
39
  export async function queryMutationsOverTimeData(
37
40
  lapisFilter: LapisFilter,
38
41
  sequenceType: 'nucleotide' | 'amino acid',
@@ -43,6 +46,15 @@ export async function queryMutationsOverTimeData(
43
46
  ) {
44
47
  const allDates = await getDatesInDataset(lapisFilter, lapis, granularity, lapisDateField, signal);
45
48
 
49
+ if (allDates.length > MAX_NUMBER_OF_GRID_COLUMNS) {
50
+ throw new UserFacingError(
51
+ 'Too many dates',
52
+ `The dataset would contain ${allDates.length} date intervals. ` +
53
+ `Please reduce the number to below ${MAX_NUMBER_OF_GRID_COLUMNS} to display the data. ` +
54
+ 'You can achieve this by either narrowing the date range in the provided LAPIS filter or by selecting a larger granularity.',
55
+ );
56
+ }
57
+
46
58
  const subQueries = allDates.map(async (date) => {
47
59
  const dateFrom = date.firstDay.toString();
48
60
  const dateTo = date.lastDay.toString();
@@ -133,14 +145,17 @@ function fetchAndPrepareSubstitutionsOrDeletions(filter: LapisFilter, sequenceTy
133
145
  }
134
146
 
135
147
  export function groupByMutation(data: MutationOverTimeData[]) {
136
- const dataArray = new Map2d<Substitution | Deletion, Temporal, number>(
148
+ const dataArray = new Map2d<Substitution | Deletion, Temporal, MutationOverTimeMutationValue>(
137
149
  (mutation) => mutation.code,
138
150
  (date) => date.toString(),
139
151
  );
140
152
 
141
153
  data.forEach((mutationData) => {
142
154
  mutationData.mutations.forEach((mutationEntry) => {
143
- dataArray.set(mutationEntry.mutation, mutationData.date, mutationEntry.proportion);
155
+ dataArray.set(mutationEntry.mutation, mutationData.date, {
156
+ count: mutationEntry.count,
157
+ proportion: mutationEntry.proportion,
158
+ });
144
159
  });
145
160
  });
146
161
 
@@ -150,14 +165,14 @@ export function groupByMutation(data: MutationOverTimeData[]) {
150
165
  }
151
166
 
152
167
  function addZeroValuesForDatesWithNoMutationData(
153
- dataArray: Map2d<Substitution | Deletion, Temporal, number>,
168
+ dataArray: Map2d<Substitution | Deletion, Temporal, MutationOverTimeMutationValue>,
154
169
  data: MutationOverTimeData[],
155
170
  ) {
156
171
  if (dataArray.getFirstAxisKeys().length !== 0) {
157
172
  const someMutation = dataArray.getFirstAxisKeys()[0];
158
173
  data.forEach((mutationData) => {
159
174
  if (mutationData.mutations.length === 0) {
160
- dataArray.set(someMutation, mutationData.date, 0);
175
+ dataArray.set(someMutation, mutationData.date, { count: 0, proportion: 0 });
161
176
  }
162
177
  });
163
178
  }
@@ -60,6 +60,8 @@ describe('YearMonthDay', () => {
60
60
  // seems to be a bug in dayjs: https://github.com/iamkun/dayjs/issues/2620
61
61
  expect(underTest.week.text).equal('2019-01');
62
62
  expect(underTest.text).equal('2020-01-01');
63
+ expect(underTest.firstDay.text).equal('2020-01-01');
64
+ expect(underTest.lastDay.text).equal('2020-01-01');
63
65
  });
64
66
  });
65
67
 
@@ -71,6 +73,7 @@ describe('YearWeek', () => {
71
73
  expect(underTest.isoWeekNumber).equal(2);
72
74
  expect(underTest.firstDay.text).equal('2020-01-06');
73
75
  expect(underTest.text).equal('2020-02');
76
+ expect(underTest.lastDay.text).equal('2020-01-12');
74
77
  });
75
78
  });
76
79
 
@@ -82,6 +85,7 @@ describe('YearMonth', () => {
82
85
  expect(underTest.monthNumber).equal(1);
83
86
  expect(underTest.text).equal('2020-01');
84
87
  expect(underTest.firstDay.text).equal('2020-01-01');
88
+ expect(underTest.lastDay.text).equal('2020-01-31');
85
89
  });
86
90
  });
87
91
 
@@ -93,5 +97,6 @@ describe('Year', () => {
93
97
  expect(underTest.text).equal('2020');
94
98
  expect(underTest.firstDay.text).equal('2020-01-01');
95
99
  expect(underTest.firstMonth.text).equal('2020-01');
100
+ expect(underTest.lastDay.text).equal('2020-12-31');
96
101
  });
97
102
  });