@genspectrum/dashboard-components 1.8.2 → 1.9.1
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 +2 -2
- package/dist/components.d.ts +25 -25
- package/dist/components.js +165 -44
- package/dist/components.js.map +1 -1
- package/dist/util.d.ts +25 -25
- package/package.json +1 -1
- package/src/preact/components/downshift-combobox.tsx +2 -1
- package/src/preact/components/portal-tooltip.tsx +129 -0
- package/src/preact/components/tooltip.tsx +32 -16
- package/src/preact/lineageFilter/fetchLineageAutocompleteList.spec.ts +72 -0
- package/src/preact/lineageFilter/fetchLineageAutocompleteList.ts +81 -19
- package/src/preact/lineageFilter/lineage-filter.stories.tsx +57 -1
- package/src/preact/lineageFilter/lineage-filter.tsx +1 -1
- package/src/preact/locationFilter/location-filter.tsx +3 -1
- package/src/preact/mutationsOverTime/mutations-over-time-grid-tooltip.tsx +1 -1
- package/src/preact/mutationsOverTime/mutations-over-time-grid.tsx +13 -7
- package/src/preact/mutationsOverTime/mutations-over-time.tsx +13 -4
- package/src/preact/textFilter/text-filter.tsx +3 -1
- package/src/preact/wastewater/mutationsOverTime/wastewater-mutations-over-time.tsx +8 -4
- package/src/web-components/input/gs-lineage-filter.stories.ts +1 -1
- package/src/web-components/input/gs-text-filter.stories.ts +1 -1
- package/standalone-bundle/dashboard-components.js +4452 -4361
- package/standalone-bundle/dashboard-components.js.map +1 -1
package/dist/util.d.ts
CHANGED
|
@@ -939,6 +939,22 @@ declare global {
|
|
|
939
939
|
}
|
|
940
940
|
|
|
941
941
|
|
|
942
|
+
declare global {
|
|
943
|
+
interface HTMLElementTagNameMap {
|
|
944
|
+
'gs-wastewater-mutations-over-time': WastewaterMutationsOverTimeComponent;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
declare global {
|
|
950
|
+
namespace JSX {
|
|
951
|
+
interface IntrinsicElements {
|
|
952
|
+
'gs-wastewater-mutations-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
|
|
942
958
|
declare global {
|
|
943
959
|
interface HTMLElementTagNameMap {
|
|
944
960
|
'gs-genome-data-viewer': GenomeDataViewerComponent;
|
|
@@ -1037,7 +1053,7 @@ declare global {
|
|
|
1037
1053
|
|
|
1038
1054
|
declare global {
|
|
1039
1055
|
interface HTMLElementTagNameMap {
|
|
1040
|
-
'gs-
|
|
1056
|
+
'gs-number-sequences-over-time': NumberSequencesOverTimeComponent;
|
|
1041
1057
|
}
|
|
1042
1058
|
}
|
|
1043
1059
|
|
|
@@ -1045,7 +1061,7 @@ declare global {
|
|
|
1045
1061
|
declare global {
|
|
1046
1062
|
namespace JSX {
|
|
1047
1063
|
interface IntrinsicElements {
|
|
1048
|
-
'gs-
|
|
1064
|
+
'gs-number-sequences-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
|
|
1049
1065
|
}
|
|
1050
1066
|
}
|
|
1051
1067
|
}
|
|
@@ -1053,7 +1069,7 @@ declare global {
|
|
|
1053
1069
|
|
|
1054
1070
|
declare global {
|
|
1055
1071
|
interface HTMLElementTagNameMap {
|
|
1056
|
-
'gs-
|
|
1072
|
+
'gs-mutations-over-time': MutationsOverTimeComponent;
|
|
1057
1073
|
}
|
|
1058
1074
|
}
|
|
1059
1075
|
|
|
@@ -1061,7 +1077,7 @@ declare global {
|
|
|
1061
1077
|
declare global {
|
|
1062
1078
|
namespace JSX {
|
|
1063
1079
|
interface IntrinsicElements {
|
|
1064
|
-
'gs-
|
|
1080
|
+
'gs-mutations-over-time': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
|
|
1065
1081
|
}
|
|
1066
1082
|
}
|
|
1067
1083
|
}
|
|
@@ -1101,7 +1117,10 @@ declare global {
|
|
|
1101
1117
|
|
|
1102
1118
|
declare global {
|
|
1103
1119
|
interface HTMLElementTagNameMap {
|
|
1104
|
-
'gs-
|
|
1120
|
+
'gs-location-filter': LocationFilterComponent;
|
|
1121
|
+
}
|
|
1122
|
+
interface HTMLElementEventMap {
|
|
1123
|
+
[gsEventNames.locationChanged]: LocationChangedEvent;
|
|
1105
1124
|
}
|
|
1106
1125
|
}
|
|
1107
1126
|
|
|
@@ -1109,7 +1128,7 @@ declare global {
|
|
|
1109
1128
|
declare global {
|
|
1110
1129
|
namespace JSX {
|
|
1111
1130
|
interface IntrinsicElements {
|
|
1112
|
-
'gs-
|
|
1131
|
+
'gs-location-filter': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
|
|
1113
1132
|
}
|
|
1114
1133
|
}
|
|
1115
1134
|
}
|
|
@@ -1135,25 +1154,6 @@ declare global {
|
|
|
1135
1154
|
}
|
|
1136
1155
|
|
|
1137
1156
|
|
|
1138
|
-
declare global {
|
|
1139
|
-
interface HTMLElementTagNameMap {
|
|
1140
|
-
'gs-location-filter': LocationFilterComponent;
|
|
1141
|
-
}
|
|
1142
|
-
interface HTMLElementEventMap {
|
|
1143
|
-
[gsEventNames.locationChanged]: LocationChangedEvent;
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
declare global {
|
|
1149
|
-
namespace JSX {
|
|
1150
|
-
interface IntrinsicElements {
|
|
1151
|
-
'gs-location-filter': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
|
|
1152
|
-
}
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
1157
|
declare global {
|
|
1158
1158
|
interface HTMLElementTagNameMap {
|
|
1159
1159
|
'gs-text-filter': TextFilterComponent;
|
package/package.json
CHANGED
|
@@ -64,6 +64,7 @@ export function DownshiftCombobox<Item>({
|
|
|
64
64
|
getItemProps,
|
|
65
65
|
inputValue,
|
|
66
66
|
closeMenu,
|
|
67
|
+
reset,
|
|
67
68
|
} = useCombobox({
|
|
68
69
|
onInputValueChange({ inputValue }) {
|
|
69
70
|
setInputIsInvalid(false);
|
|
@@ -97,7 +98,7 @@ export function DownshiftCombobox<Item>({
|
|
|
97
98
|
};
|
|
98
99
|
|
|
99
100
|
const clearInput = () => {
|
|
100
|
-
|
|
101
|
+
reset();
|
|
101
102
|
};
|
|
102
103
|
|
|
103
104
|
const buttonRef = useRef(null);
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { type FunctionComponent } from 'preact';
|
|
2
|
+
import { type CSSProperties, createPortal } from 'preact/compat';
|
|
3
|
+
import { useState, useRef, useLayoutEffect } from 'preact/hooks';
|
|
4
|
+
import { type JSXInternal } from 'preact/src/jsx';
|
|
5
|
+
|
|
6
|
+
import { type TooltipPosition, TOOLTIP_BASE_STYLES } from './tooltip';
|
|
7
|
+
|
|
8
|
+
export type PortalTooltipProps = {
|
|
9
|
+
content: string | JSXInternal.Element;
|
|
10
|
+
position?: TooltipPosition;
|
|
11
|
+
tooltipStyle?: CSSProperties;
|
|
12
|
+
portalTarget: HTMLElement | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A portal-based tooltip component that renders content in a specified DOM element.
|
|
17
|
+
*
|
|
18
|
+
* Unlike the regular `Tooltip` component, this uses Preact portals to render the tooltip
|
|
19
|
+
* at a specific location in the DOM with fixed positioning. This is useful when:
|
|
20
|
+
* - The tooltip needs to escape overflow constraints from parent containers
|
|
21
|
+
* - You need precise control over the tooltip's rendering location
|
|
22
|
+
* - Parent containers have `overflow: hidden` or other clipping styles
|
|
23
|
+
*
|
|
24
|
+
* **Important:** The `portalTarget` element should still be within the same shadow DOM as the
|
|
25
|
+
* component to ensure proper styling and encapsulation. Typically, this is a container element
|
|
26
|
+
* at the root of your component. Do not use `document.body`.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```tsx
|
|
30
|
+
* const portalTarget = document.getElementById('tooltip-root');
|
|
31
|
+
*
|
|
32
|
+
* <PortalTooltip
|
|
33
|
+
* content="This is a portal tooltip"
|
|
34
|
+
* position="top"
|
|
35
|
+
* portalTarget={portalTarget}
|
|
36
|
+
* >
|
|
37
|
+
* <button>Hover me</button>
|
|
38
|
+
* </PortalTooltip>
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
const PortalTooltip: FunctionComponent<PortalTooltipProps> = ({
|
|
42
|
+
children,
|
|
43
|
+
content,
|
|
44
|
+
position = 'bottom',
|
|
45
|
+
tooltipStyle,
|
|
46
|
+
portalTarget,
|
|
47
|
+
}) => {
|
|
48
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
49
|
+
const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 });
|
|
50
|
+
const triggerRef = useRef<HTMLDivElement>(null);
|
|
51
|
+
const tooltipRef = useRef<HTMLDivElement>(null);
|
|
52
|
+
|
|
53
|
+
useLayoutEffect(() => {
|
|
54
|
+
if (isHovered && triggerRef.current !== null && tooltipRef.current !== null) {
|
|
55
|
+
const triggerRect = triggerRef.current.getBoundingClientRect();
|
|
56
|
+
const tooltipRect = tooltipRef.current.getBoundingClientRect();
|
|
57
|
+
const newPosition = calculateTooltipPosition(triggerRect, tooltipRect, position);
|
|
58
|
+
setTooltipPosition(newPosition);
|
|
59
|
+
}
|
|
60
|
+
}, [isHovered, position]);
|
|
61
|
+
|
|
62
|
+
const tooltipContent = (
|
|
63
|
+
<div
|
|
64
|
+
ref={tooltipRef}
|
|
65
|
+
className={`fixed ${TOOLTIP_BASE_STYLES} ${isHovered ? 'visible' : 'invisible'}`}
|
|
66
|
+
style={Object.assign({}, tooltipStyle, { top: tooltipPosition.top, left: tooltipPosition.left })}
|
|
67
|
+
>
|
|
68
|
+
{content}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<>
|
|
74
|
+
<div ref={triggerRef} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)}>
|
|
75
|
+
{children}
|
|
76
|
+
</div>
|
|
77
|
+
{portalTarget !== null && createPortal(tooltipContent, portalTarget)}
|
|
78
|
+
</>
|
|
79
|
+
);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export default PortalTooltip;
|
|
83
|
+
|
|
84
|
+
function calculateTooltipPosition(
|
|
85
|
+
triggerRect: DOMRect,
|
|
86
|
+
tooltipRect: DOMRect,
|
|
87
|
+
position: TooltipPosition,
|
|
88
|
+
): { top: number; left: number } {
|
|
89
|
+
const gap = 4;
|
|
90
|
+
let top;
|
|
91
|
+
let left;
|
|
92
|
+
|
|
93
|
+
switch (position) {
|
|
94
|
+
case 'top':
|
|
95
|
+
top = triggerRect.top - tooltipRect.height - gap;
|
|
96
|
+
left = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2;
|
|
97
|
+
break;
|
|
98
|
+
case 'top-start':
|
|
99
|
+
top = triggerRect.top - tooltipRect.height - gap;
|
|
100
|
+
left = triggerRect.left;
|
|
101
|
+
break;
|
|
102
|
+
case 'top-end':
|
|
103
|
+
top = triggerRect.top - tooltipRect.height - gap;
|
|
104
|
+
left = triggerRect.right - tooltipRect.width;
|
|
105
|
+
break;
|
|
106
|
+
case 'bottom':
|
|
107
|
+
top = triggerRect.bottom + gap;
|
|
108
|
+
left = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2;
|
|
109
|
+
break;
|
|
110
|
+
case 'bottom-start':
|
|
111
|
+
top = triggerRect.bottom + gap;
|
|
112
|
+
left = triggerRect.left;
|
|
113
|
+
break;
|
|
114
|
+
case 'bottom-end':
|
|
115
|
+
top = triggerRect.bottom + gap;
|
|
116
|
+
left = triggerRect.right - tooltipRect.width;
|
|
117
|
+
break;
|
|
118
|
+
case 'left':
|
|
119
|
+
top = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2;
|
|
120
|
+
left = triggerRect.left - tooltipRect.width - gap;
|
|
121
|
+
break;
|
|
122
|
+
case 'right':
|
|
123
|
+
top = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2;
|
|
124
|
+
left = triggerRect.right + gap;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { top, left };
|
|
129
|
+
}
|
|
@@ -18,6 +18,38 @@ export type TooltipProps = {
|
|
|
18
18
|
tooltipStyle?: CSSProperties;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
export const TOOLTIP_BASE_STYLES = 'z-10 w-max bg-white p-4 border border-gray-200 rounded-md';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A simple CSS-based tooltip component that displays content on hover.
|
|
25
|
+
*
|
|
26
|
+
* **Note:** If you need the tooltip to escape overflow constraints or render at a specific
|
|
27
|
+
* location in the DOM (e.g., to avoid clipping by parent containers with `overflow: hidden`),
|
|
28
|
+
* use `PortalTooltip` instead.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```tsx
|
|
32
|
+
* <Tooltip content="This is a tooltip" position="top">
|
|
33
|
+
* <button>Hover me</button>
|
|
34
|
+
* </Tooltip>
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
const Tooltip: FunctionComponent<TooltipProps> = ({ children, content, position = 'bottom', tooltipStyle }) => {
|
|
38
|
+
return (
|
|
39
|
+
<div className={`relative group`}>
|
|
40
|
+
<div>{children}</div>
|
|
41
|
+
<div
|
|
42
|
+
className={`absolute ${TOOLTIP_BASE_STYLES} invisible group-hover:visible ${getPositionCss(position)}`}
|
|
43
|
+
style={tooltipStyle}
|
|
44
|
+
>
|
|
45
|
+
{content}
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export default Tooltip;
|
|
52
|
+
|
|
21
53
|
function getPositionCss(position?: TooltipPosition) {
|
|
22
54
|
switch (position) {
|
|
23
55
|
case 'top':
|
|
@@ -40,19 +72,3 @@ function getPositionCss(position?: TooltipPosition) {
|
|
|
40
72
|
return '';
|
|
41
73
|
}
|
|
42
74
|
}
|
|
43
|
-
|
|
44
|
-
const Tooltip: FunctionComponent<TooltipProps> = ({ children, content, position = 'bottom', tooltipStyle }) => {
|
|
45
|
-
return (
|
|
46
|
-
<div className={`relative group`}>
|
|
47
|
-
<div>{children}</div>
|
|
48
|
-
<div
|
|
49
|
-
className={`absolute z-10 w-max bg-white p-4 border border-gray-200 rounded-md invisible group-hover:visible ${getPositionCss(position)}`}
|
|
50
|
-
style={tooltipStyle}
|
|
51
|
-
>
|
|
52
|
-
{content}
|
|
53
|
-
</div>
|
|
54
|
-
</div>
|
|
55
|
-
);
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
export default Tooltip;
|
|
@@ -321,4 +321,76 @@ describe('fetchLineageAutocompleteList', () => {
|
|
|
321
321
|
},
|
|
322
322
|
]);
|
|
323
323
|
});
|
|
324
|
+
|
|
325
|
+
test('should include prefix aliases that are missing from lineage tree', async () => {
|
|
326
|
+
lapisRequestMocks.aggregated(
|
|
327
|
+
{ fields: [lineageField], ...lapisFilter },
|
|
328
|
+
{
|
|
329
|
+
data: [
|
|
330
|
+
{
|
|
331
|
+
[lineageField]: 'BA.3.2.1',
|
|
332
|
+
count: 1,
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
[lineageField]: 'BA.3.2.2',
|
|
336
|
+
count: 2,
|
|
337
|
+
},
|
|
338
|
+
],
|
|
339
|
+
},
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
lapisRequestMocks.lineageDefinition(
|
|
343
|
+
{
|
|
344
|
+
'B.1.1.529.3.2': {
|
|
345
|
+
aliases: ['BA.3.2'],
|
|
346
|
+
},
|
|
347
|
+
'BA.3.2.1': {
|
|
348
|
+
parents: ['B.1.1.529.3.2'],
|
|
349
|
+
aliases: ['B.1.1.529.3.2.1'],
|
|
350
|
+
},
|
|
351
|
+
'BA.3.2.2': {
|
|
352
|
+
parents: ['B.1.1.529.3.2'],
|
|
353
|
+
aliases: ['B.1.1.529.3.2.2'],
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
lineageField,
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
const result = await fetchLineageAutocompleteList({
|
|
360
|
+
lapisUrl: DUMMY_LAPIS_URL,
|
|
361
|
+
lapisField: lineageField,
|
|
362
|
+
lapisFilter,
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
expect(result).to.deep.equal([
|
|
366
|
+
{
|
|
367
|
+
lineage: 'B.1.1.529.3.2',
|
|
368
|
+
count: 0,
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
lineage: 'B.1.1.529.3.2*',
|
|
372
|
+
count: 3,
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
lineage: 'BA.3.2*',
|
|
376
|
+
count: 3, // Same as B.1.1.529.3.2* (includes .3.2 and .3.2.1)
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
lineage: 'BA.3.2.1',
|
|
380
|
+
count: 1,
|
|
381
|
+
},
|
|
382
|
+
{
|
|
383
|
+
lineage: 'BA.3.2.1*',
|
|
384
|
+
count: 1,
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
lineage: 'BA.3.2.2',
|
|
388
|
+
count: 2,
|
|
389
|
+
},
|
|
390
|
+
{
|
|
391
|
+
lineage: 'BA.3.2.2*',
|
|
392
|
+
count: 2,
|
|
393
|
+
},
|
|
394
|
+
]);
|
|
395
|
+
});
|
|
324
396
|
});
|
|
@@ -2,6 +2,10 @@ import { fetchLineageDefinition } from '../../lapisApi/lapisApi';
|
|
|
2
2
|
import { FetchAggregatedOperator } from '../../operator/FetchAggregatedOperator';
|
|
3
3
|
import type { LapisFilter } from '../../types';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Generates the autocomplete list for lineage search. It includes lineages with wild cards
|
|
7
|
+
* (i.e. "BA.3.2.1" and "BA.3.2.1*") as well as all prefixes of lineages with an asterisk ("BA.3.2*").
|
|
8
|
+
*/
|
|
5
9
|
export async function fetchLineageAutocompleteList({
|
|
6
10
|
lapisUrl,
|
|
7
11
|
lapisField,
|
|
@@ -13,31 +17,43 @@ export async function fetchLineageAutocompleteList({
|
|
|
13
17
|
lapisFilter?: LapisFilter;
|
|
14
18
|
signal?: AbortSignal;
|
|
15
19
|
}): Promise<LineageItem[]> {
|
|
16
|
-
const [countsByLineage, lineageTree] = await Promise.all([
|
|
20
|
+
const [countsByLineage, { lineageTree, aliasMapping }] = await Promise.all([
|
|
17
21
|
getCountsByLineage({
|
|
18
22
|
lapisUrl,
|
|
19
23
|
lapisField,
|
|
20
24
|
lapisFilter,
|
|
21
25
|
signal,
|
|
22
26
|
}),
|
|
23
|
-
|
|
27
|
+
getLineageTreeAndAliases({ lapisUrl, lapisField, signal }),
|
|
24
28
|
]);
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
30
|
+
const prefixToLineage = findMissingPrefixMappings(lineageTree, aliasMapping);
|
|
31
|
+
|
|
32
|
+
// Combine actual lineages with their wildcard versions
|
|
33
|
+
const actualLineageItems = Array.from(lineageTree.keys()).flatMap((lineage) => [
|
|
34
|
+
{
|
|
35
|
+
lineage,
|
|
36
|
+
count: countsByLineage.get(lineage) ?? 0,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
lineage: `${lineage}*`,
|
|
40
|
+
count: getCountsIncludingSublineages(lineage, lineageTree, countsByLineage),
|
|
41
|
+
},
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
// Add prefix alias items with wildcard and their counts
|
|
45
|
+
const prefixAliasItems = Array.from(prefixToLineage.entries()).map(([prefix, actualLineage]) => ({
|
|
46
|
+
lineage: `${prefix}*`,
|
|
47
|
+
count: getCountsIncludingSublineages(actualLineage, lineageTree, countsByLineage),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
// Combine and sort all items (asterisk before period for same prefix)
|
|
51
|
+
return [...actualLineageItems, ...prefixAliasItems].sort((a, b) => {
|
|
52
|
+
// Replace * with a character that sorts before . in ASCII
|
|
53
|
+
const aKey = a.lineage.replace(/\*/g, ' ');
|
|
54
|
+
const bKey = b.lineage.replace(/\*/g, ' ');
|
|
55
|
+
return aKey.localeCompare(bKey);
|
|
56
|
+
});
|
|
41
57
|
}
|
|
42
58
|
|
|
43
59
|
export type LineageItem = { lineage: string; count: number };
|
|
@@ -61,7 +77,7 @@ async function getCountsByLineage({
|
|
|
61
77
|
return new Map<string, number>(countsByLineageArray.map((value) => [value[lapisField], value.count]));
|
|
62
78
|
}
|
|
63
79
|
|
|
64
|
-
async function
|
|
80
|
+
async function getLineageTreeAndAliases({
|
|
65
81
|
lapisUrl,
|
|
66
82
|
lapisField,
|
|
67
83
|
signal,
|
|
@@ -73,12 +89,17 @@ async function getLineageTree({
|
|
|
73
89
|
const lineageDefinitions = await fetchLineageDefinition({ lapisUrl, lapisField, signal });
|
|
74
90
|
|
|
75
91
|
const lineageTree = new Map<string, { children: string[] }>();
|
|
92
|
+
const aliasMapping = new Map<string, string[]>();
|
|
76
93
|
|
|
77
94
|
Object.entries(lineageDefinitions).forEach(([lineage, definition]) => {
|
|
78
95
|
if (!lineageTree.has(lineage)) {
|
|
79
96
|
lineageTree.set(lineage, { children: [] });
|
|
80
97
|
}
|
|
81
98
|
|
|
99
|
+
if (definition.aliases && definition.aliases.length > 0) {
|
|
100
|
+
aliasMapping.set(lineage, definition.aliases);
|
|
101
|
+
}
|
|
102
|
+
|
|
82
103
|
definition.parents?.forEach((parent) => {
|
|
83
104
|
const parentChildren = lineageTree.get(parent)?.children;
|
|
84
105
|
|
|
@@ -88,7 +109,7 @@ async function getLineageTree({
|
|
|
88
109
|
});
|
|
89
110
|
});
|
|
90
111
|
|
|
91
|
-
return lineageTree;
|
|
112
|
+
return { lineageTree, aliasMapping };
|
|
92
113
|
}
|
|
93
114
|
|
|
94
115
|
function getCountsIncludingSublineages(
|
|
@@ -115,3 +136,44 @@ function getAllDescendants(lineage: string, lineageTree: Map<string, { children:
|
|
|
115
136
|
|
|
116
137
|
return new Set([...children, ...childrenOfChildren.flatMap((child) => Array.from(child))]);
|
|
117
138
|
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* This function finds prefixes (i.e. "BA.3.2" for "BA.3.2.1") that are not in the lineageTree,
|
|
142
|
+
* but do appear as an alias. It returns a reverse mapping for those prefixes, back to a lineage
|
|
143
|
+
* that can be found in the lineageTree (i.e. "BA.3.2" -> "B.1.1.529.3.2").
|
|
144
|
+
*/
|
|
145
|
+
function findMissingPrefixMappings(
|
|
146
|
+
lineageTree: Map<string, { children: string[] }>,
|
|
147
|
+
aliasMapping: Map<string, string[]>,
|
|
148
|
+
): Map<string, string> {
|
|
149
|
+
const lineages = Array.from(lineageTree.keys());
|
|
150
|
+
const lineagesSet = new Set(lineages);
|
|
151
|
+
|
|
152
|
+
// Generate all prefixes for each lineage (e.g., "A.B.1" -> ["A", "A.B", "A.B.1"])
|
|
153
|
+
const allPrefixes = lineages.flatMap((lineage) => {
|
|
154
|
+
const parts = lineage.split('.');
|
|
155
|
+
return parts.map((_, i) => parts.slice(0, i + 1).join('.'));
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Find prefixes that are NOT in the actual lineages list
|
|
159
|
+
const missingPrefixes = new Set(allPrefixes.filter((prefix) => !lineagesSet.has(prefix)));
|
|
160
|
+
|
|
161
|
+
// Create reverse alias mapping: alias -> original lineage
|
|
162
|
+
const reverseAliasMapping = new Map<string, string>();
|
|
163
|
+
aliasMapping.forEach((aliases, lineage) => {
|
|
164
|
+
aliases.forEach((alias) => {
|
|
165
|
+
reverseAliasMapping.set(alias, lineage);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Map missing prefixes to their actual lineage names via reverse alias lookup
|
|
170
|
+
const prefixToLineage = new Map<string, string>();
|
|
171
|
+
missingPrefixes.forEach((prefix) => {
|
|
172
|
+
const actualLineage = reverseAliasMapping.get(prefix);
|
|
173
|
+
if (actualLineage) {
|
|
174
|
+
prefixToLineage.set(prefix, actualLineage);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
return prefixToLineage;
|
|
179
|
+
}
|
|
@@ -108,7 +108,7 @@ export const Default: StoryObj<LineageFilterProps> = {
|
|
|
108
108
|
const input = await inputField(canvas);
|
|
109
109
|
await userEvent.clear(input);
|
|
110
110
|
await userEvent.type(input, 'B.1');
|
|
111
|
-
await userEvent.click(canvas.getByRole('option', { name: 'B.1(
|
|
111
|
+
await userEvent.click(canvas.getByRole('option', { name: 'B.1(53,802)' }));
|
|
112
112
|
|
|
113
113
|
await waitFor(() => {
|
|
114
114
|
return expect(lineageChangedListenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
|
|
@@ -187,6 +187,62 @@ export const WithHideCountsTrue: StoryObj<LineageFilterProps> = {
|
|
|
187
187
|
},
|
|
188
188
|
};
|
|
189
189
|
|
|
190
|
+
export const EnterAndClearMultipleTimes: StoryObj<LineageFilterProps> = {
|
|
191
|
+
...Default,
|
|
192
|
+
play: async ({ canvasElement, step }) => {
|
|
193
|
+
const { canvas, lineageChangedListenerMock } = await prepare(canvasElement, step);
|
|
194
|
+
const input = await inputField(canvas);
|
|
195
|
+
const inputContainer = canvas.getByRole('combobox').parentElement;
|
|
196
|
+
const clearSelectionButton = await canvas.findByLabelText('clear selection');
|
|
197
|
+
|
|
198
|
+
await step('clear field initially', async () => {
|
|
199
|
+
await userEvent.clear(input);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await step('enter F in the input field', async () => {
|
|
203
|
+
await userEvent.type(input, 'F');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
await step('clear selection using clear button', async () => {
|
|
207
|
+
await userEvent.click(clearSelectionButton);
|
|
208
|
+
|
|
209
|
+
await waitFor(() => {
|
|
210
|
+
return expect(lineageChangedListenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
|
|
211
|
+
pangoLineage: undefined,
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
await step('verify input field is empty after clearing', async () => {
|
|
217
|
+
await expect(input).toHaveValue('');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await step('verify no red border after clearing', async () => {
|
|
221
|
+
await expect(inputContainer).not.toHaveClass('input-error');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// do it again
|
|
225
|
+
|
|
226
|
+
await step('enter F in the input field again', async () => {
|
|
227
|
+
await userEvent.type(input, 'F');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await step('clear selection using clear button again', async () => {
|
|
231
|
+
await userEvent.click(clearSelectionButton);
|
|
232
|
+
|
|
233
|
+
await waitFor(() => {
|
|
234
|
+
return expect(lineageChangedListenerMock.mock.calls.at(-1)![0].detail).toStrictEqual({
|
|
235
|
+
pangoLineage: undefined,
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
await step('verify input field is empty after clearing again', async () => {
|
|
241
|
+
await expect(input).toHaveValue('');
|
|
242
|
+
});
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
|
|
190
246
|
async function prepare(canvasElement: HTMLElement, step: StepFunction<PreactRenderer, unknown>) {
|
|
191
247
|
const canvas = within(canvasElement);
|
|
192
248
|
|
|
@@ -99,7 +99,7 @@ const LineageSelector = ({
|
|
|
99
99
|
formatItemInList={(item: LineageItem) => (
|
|
100
100
|
<p>
|
|
101
101
|
<span>{item.lineage}</span>
|
|
102
|
-
{!hideCounts && <span className='ml-2 text-gray-500'>({item.count})</span>}
|
|
102
|
+
{!hideCounts && <span className='ml-2 text-gray-500'>({item.count.toLocaleString('en-US')})</span>}
|
|
103
103
|
</p>
|
|
104
104
|
)}
|
|
105
105
|
/>
|
|
@@ -112,7 +112,9 @@ const LocationSelector = ({
|
|
|
112
112
|
<>
|
|
113
113
|
<p>
|
|
114
114
|
<span>{item.label}</span>
|
|
115
|
-
{!hideCounts &&
|
|
115
|
+
{!hideCounts && (
|
|
116
|
+
<span className='ml-2 text-gray-500'>({item.count.toLocaleString('en-US')})</span>
|
|
117
|
+
)}
|
|
116
118
|
</p>
|
|
117
119
|
<span className='text-sm text-gray-500'>{item.description}</span>
|
|
118
120
|
</>
|
|
@@ -22,7 +22,7 @@ export const MutationsOverTimeGridTooltip: FunctionComponent<MutationsOverTimeGr
|
|
|
22
22
|
const dateClass = toTemporalClass(date);
|
|
23
23
|
|
|
24
24
|
return (
|
|
25
|
-
<div>
|
|
25
|
+
<div className='text-center'>
|
|
26
26
|
<p>
|
|
27
27
|
<span className='font-bold'>{dateClass.englishName()}</span>
|
|
28
28
|
</p>
|