@genspectrum/dashboard-components 1.17.0 → 1.18.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.
- package/custom-elements.json +4 -4
- package/dist/components.d.ts +141 -41
- package/dist/components.js +77 -47
- package/dist/components.js.map +1 -1
- package/dist/util.d.ts +209 -49
- package/package.json +1 -1
- package/src/preact/MutationAnnotationsContext.spec.tsx +82 -10
- package/src/preact/MutationAnnotationsContext.tsx +92 -44
- package/src/preact/components/annotated-mutation.stories.tsx +31 -0
- package/src/preact/components/annotated-mutation.tsx +5 -5
- package/src/preact/mutationsOverTime/getFilteredMutationCodes.spec.ts +2 -2
- package/src/preact/mutationsOverTime/getFilteredMutationCodes.ts +5 -5
- package/src/web-components/gs-app.ts +8 -4
- package/src/web-components/mutation-annotations-context.ts +13 -5
- package/src/web-components/mutationAnnotations.mdx +29 -0
- package/standalone-bundle/dashboard-components.js +3151 -3120
- package/standalone-bundle/dashboard-components.js.map +1 -1
|
@@ -11,12 +11,18 @@ import { ErrorDisplay } from './components/error-display';
|
|
|
11
11
|
import { ResizeContainer } from './components/resize-container';
|
|
12
12
|
import { type Mutation } from '../utils/mutations';
|
|
13
13
|
|
|
14
|
-
type
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
export type ResolvedMutationAnnotation = {
|
|
15
|
+
annotation: MutationAnnotation;
|
|
16
|
+
name: string;
|
|
17
|
+
description: string;
|
|
17
18
|
};
|
|
18
19
|
|
|
19
|
-
type
|
|
20
|
+
type AnnotationLookup = {
|
|
21
|
+
mutation: Map<string, ResolvedMutationAnnotation[]>;
|
|
22
|
+
position: Map<string, ResolvedMutationAnnotation[]>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type MutationAnnotationsContextValue = Record<SequenceType, AnnotationLookup> & {
|
|
20
26
|
rawAnnotations: MutationAnnotations;
|
|
21
27
|
};
|
|
22
28
|
|
|
@@ -32,69 +38,113 @@ const MutationAnnotationsContext = createContext<MutationAnnotationsContextValue
|
|
|
32
38
|
},
|
|
33
39
|
});
|
|
34
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Validates and provides mutation annotations to all descendant components.
|
|
43
|
+
* Accepts the raw MutationAnnotations config, builds the internal lookup index, and stores it in context.
|
|
44
|
+
* Renders an error message if the provided annotations fail schema validation.
|
|
45
|
+
*/
|
|
35
46
|
export const MutationAnnotationsContextProvider: FunctionalComponent<
|
|
36
47
|
Omit<ComponentProps<typeof MutationAnnotationsContext.Provider>, 'value'> & { value: MutationAnnotations }
|
|
37
48
|
> = ({ value, children }) => {
|
|
38
|
-
const parseResult = useMemo(() =>
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}, [value]);
|
|
49
|
+
const parseResult = useMemo(() => mutationAnnotationsSchema.safeParse(value), [value]);
|
|
50
|
+
const contextValue = useMemo(
|
|
51
|
+
() =>
|
|
52
|
+
parseResult.success
|
|
53
|
+
? { success: true as const, value: buildAnnotationIndex(parseResult.data) }
|
|
54
|
+
: { success: false as const, error: parseResult.error },
|
|
55
|
+
[parseResult],
|
|
56
|
+
);
|
|
47
57
|
|
|
48
|
-
if (!
|
|
58
|
+
if (!contextValue.success) {
|
|
49
59
|
return (
|
|
50
60
|
<ResizeContainer size={{ width: '100%' }}>
|
|
51
|
-
<ErrorDisplay error={
|
|
61
|
+
<ErrorDisplay error={contextValue.error} layout='vertical' />
|
|
52
62
|
</ResizeContainer>
|
|
53
63
|
);
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
return (
|
|
57
|
-
<MutationAnnotationsContext.Provider value={
|
|
67
|
+
<MutationAnnotationsContext.Provider value={contextValue.value}>{children}</MutationAnnotationsContext.Provider>
|
|
58
68
|
);
|
|
59
69
|
};
|
|
60
70
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Indexes a flat list of MutationAnnotations into fast lookup maps, resolving per-entry name/description overrides
|
|
73
|
+
* eagerly. Called once (memoized) when the annotations config is set on the provider.
|
|
74
|
+
*
|
|
75
|
+
* Returns two maps per sequence type — one keyed by exact mutation code, one by position string — each mapping to
|
|
76
|
+
* the list of ResolvedMutationAnnotations that apply to that key.
|
|
77
|
+
*/
|
|
78
|
+
export function buildAnnotationIndex(value: MutationAnnotations): MutationAnnotationsContextValue {
|
|
79
|
+
const nucleotideMutationMap = new Map<string, ResolvedMutationAnnotation[]>();
|
|
80
|
+
const nucleotidePositionMap = new Map<string, ResolvedMutationAnnotation[]>();
|
|
81
|
+
const aminoAcidMutationMap = new Map<string, ResolvedMutationAnnotation[]>();
|
|
82
|
+
const aminoAcidPositionMap = new Map<string, ResolvedMutationAnnotation[]>();
|
|
66
83
|
|
|
67
84
|
value.forEach((annotation) => {
|
|
68
|
-
|
|
69
|
-
|
|
85
|
+
annotation.nucleotideMutations?.forEach((entry) => {
|
|
86
|
+
addToMap(
|
|
87
|
+
nucleotideMutationMap,
|
|
88
|
+
typeof entry === 'string' ? entry : entry.mutation,
|
|
89
|
+
resolve(annotation, entry),
|
|
90
|
+
);
|
|
70
91
|
});
|
|
71
|
-
|
|
72
|
-
|
|
92
|
+
annotation.aminoAcidMutations?.forEach((entry) => {
|
|
93
|
+
addToMap(
|
|
94
|
+
aminoAcidMutationMap,
|
|
95
|
+
typeof entry === 'string' ? entry : entry.mutation,
|
|
96
|
+
resolve(annotation, entry),
|
|
97
|
+
);
|
|
73
98
|
});
|
|
74
|
-
|
|
75
|
-
|
|
99
|
+
annotation.nucleotidePositions?.forEach((entry) => {
|
|
100
|
+
addToMap(
|
|
101
|
+
nucleotidePositionMap,
|
|
102
|
+
typeof entry === 'string' ? entry : entry.position,
|
|
103
|
+
resolve(annotation, entry),
|
|
104
|
+
);
|
|
76
105
|
});
|
|
77
|
-
|
|
78
|
-
|
|
106
|
+
annotation.aminoAcidPositions?.forEach((entry) => {
|
|
107
|
+
addToMap(
|
|
108
|
+
aminoAcidPositionMap,
|
|
109
|
+
typeof entry === 'string' ? entry : entry.position,
|
|
110
|
+
resolve(annotation, entry),
|
|
111
|
+
);
|
|
79
112
|
});
|
|
80
113
|
});
|
|
81
114
|
|
|
82
115
|
return {
|
|
83
116
|
rawAnnotations: value,
|
|
84
|
-
nucleotide: { mutation:
|
|
85
|
-
'amino acid': { mutation:
|
|
117
|
+
nucleotide: { mutation: nucleotideMutationMap, position: nucleotidePositionMap },
|
|
118
|
+
'amino acid': { mutation: aminoAcidMutationMap, position: aminoAcidPositionMap },
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function resolve(
|
|
123
|
+
annotation: MutationAnnotation,
|
|
124
|
+
entry: string | { name?: string; description?: string },
|
|
125
|
+
): ResolvedMutationAnnotation {
|
|
126
|
+
const overrides = typeof entry === 'object' ? entry : undefined;
|
|
127
|
+
return {
|
|
128
|
+
annotation,
|
|
129
|
+
name: overrides?.name ?? annotation.name,
|
|
130
|
+
description: overrides?.description ?? annotation.description,
|
|
86
131
|
};
|
|
87
132
|
}
|
|
88
133
|
|
|
89
|
-
function
|
|
90
|
-
const
|
|
91
|
-
map.set(code.toUpperCase(), [...
|
|
134
|
+
function addToMap(map: Map<string, ResolvedMutationAnnotation[]>, code: string, resolved: ResolvedMutationAnnotation) {
|
|
135
|
+
const existing = map.get(code.toUpperCase()) ?? [];
|
|
136
|
+
map.set(code.toUpperCase(), [...existing, resolved]);
|
|
92
137
|
}
|
|
93
138
|
|
|
94
139
|
export function useRawMutationAnnotations() {
|
|
95
140
|
return useContext(MutationAnnotationsContext).rawAnnotations;
|
|
96
141
|
}
|
|
97
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Returns a lookup function `(mutation, sequenceType) => ResolvedMutationAnnotation[] | undefined` that, given a
|
|
145
|
+
* specific mutation, returns all annotations that apply to it with name and description already resolved.
|
|
146
|
+
* Returns undefined if no annotations match.
|
|
147
|
+
*/
|
|
98
148
|
export function useMutationAnnotationsProvider() {
|
|
99
149
|
const mutationAnnotations = useContext(MutationAnnotationsContext);
|
|
100
150
|
|
|
@@ -108,21 +158,19 @@ export function getMutationAnnotationsProvider(mutationAnnotations: MutationAnno
|
|
|
108
158
|
? `${mutation.position}`
|
|
109
159
|
: `${mutation.segment.toUpperCase()}:${mutation.position}`;
|
|
110
160
|
|
|
111
|
-
const
|
|
112
|
-
const
|
|
161
|
+
const exactMatches = mutationAnnotations[sequenceType].mutation.get(mutation.code.toUpperCase());
|
|
162
|
+
const positionMatches = mutationAnnotations[sequenceType].position.get(position);
|
|
113
163
|
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
? [...possiblePositionAnnotations, ...possibleExactAnnotations]
|
|
117
|
-
: (possiblePositionAnnotations ?? possibleExactAnnotations);
|
|
164
|
+
const combined =
|
|
165
|
+
exactMatches && positionMatches ? [...exactMatches, ...positionMatches] : (exactMatches ?? positionMatches);
|
|
118
166
|
|
|
119
|
-
const
|
|
167
|
+
const seenNames = new Set<string>();
|
|
120
168
|
|
|
121
|
-
return
|
|
122
|
-
if (
|
|
169
|
+
return combined?.filter((resolved) => {
|
|
170
|
+
if (seenNames.has(resolved.annotation.name)) {
|
|
123
171
|
return false;
|
|
124
172
|
}
|
|
125
|
-
|
|
173
|
+
seenNames.add(resolved.annotation.name);
|
|
126
174
|
return true;
|
|
127
175
|
});
|
|
128
176
|
};
|
|
@@ -128,6 +128,37 @@ export const MutationWithMultipleAnnotationEntries: StoryObj<StoryProps> = {
|
|
|
128
128
|
},
|
|
129
129
|
};
|
|
130
130
|
|
|
131
|
+
export const MutationWithPerMutationInfoOverride: StoryObj<StoryProps> = {
|
|
132
|
+
...MutationWithoutAnnotationEntry,
|
|
133
|
+
args: {
|
|
134
|
+
...MutationWithoutAnnotationEntry.args,
|
|
135
|
+
annotations: [
|
|
136
|
+
{
|
|
137
|
+
name: 'Group annotation',
|
|
138
|
+
description: 'Group-level description',
|
|
139
|
+
symbol: 'c',
|
|
140
|
+
nucleotideMutations: [
|
|
141
|
+
{
|
|
142
|
+
mutation: 'A23403G',
|
|
143
|
+
name: '3CLpro:T31C',
|
|
144
|
+
description: 'Per-mutation description for 3CLpro:T31C',
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
play: async ({ canvasElement }) => {
|
|
151
|
+
const canvas = within(canvasElement);
|
|
152
|
+
|
|
153
|
+
await waitFor(() => expect(canvas.getByText('A23403G')).toBeVisible());
|
|
154
|
+
await expect(getAnnotationIndicator(canvas)).toBeVisible();
|
|
155
|
+
|
|
156
|
+
await userEvent.click(canvas.getByText('c'));
|
|
157
|
+
await waitFor(() => expect(canvas.queryByText('3CLpro:T31C')).toBeVisible());
|
|
158
|
+
await expect(canvas.queryByText('Per-mutation description for 3CLpro:T31C')).toBeVisible();
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
131
162
|
export const AminoAcidMutationWithAnnotationEntry: StoryObj<StoryProps> = {
|
|
132
163
|
...MutationWithoutAnnotationEntry,
|
|
133
164
|
args: {
|
|
@@ -78,11 +78,11 @@ const AnnotatedMutationWithoutContext: FunctionComponent<AnnotatedMutationWithou
|
|
|
78
78
|
const modalContent = (
|
|
79
79
|
<div className='block'>
|
|
80
80
|
<InfoHeadline1>Annotations for {mutation.code}</InfoHeadline1>
|
|
81
|
-
{mutationAnnotations.map((
|
|
82
|
-
<Fragment key={annotation.name}>
|
|
83
|
-
<InfoHeadline2>{
|
|
81
|
+
{mutationAnnotations.map((resolved) => (
|
|
82
|
+
<Fragment key={resolved.annotation.name}>
|
|
83
|
+
<InfoHeadline2>{resolved.name}</InfoHeadline2>
|
|
84
84
|
<InfoParagraph>
|
|
85
|
-
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(
|
|
85
|
+
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(resolved.description) }} />
|
|
86
86
|
</InfoParagraph>
|
|
87
87
|
</Fragment>
|
|
88
88
|
))}
|
|
@@ -99,7 +99,7 @@ const AnnotatedMutationWithoutContext: FunctionComponent<AnnotatedMutationWithou
|
|
|
99
99
|
>
|
|
100
100
|
<sup className='hover:underline focus-visible:underline decoration-red-600'>
|
|
101
101
|
{mutationAnnotations
|
|
102
|
-
.map((
|
|
102
|
+
.map((resolved) => resolved.annotation.symbol)
|
|
103
103
|
.map((symbol, index) => (
|
|
104
104
|
<Fragment key={symbol}>
|
|
105
105
|
<span className='text-red-600'>{symbol}</span>
|
|
@@ -4,7 +4,7 @@ import { getFilteredMutationCodes, type MutationFilter } from './getFilteredMuta
|
|
|
4
4
|
import { type DeletionEntry, type SubstitutionEntry } from '../../types';
|
|
5
5
|
import { type Deletion, type Substitution } from '../../utils/mutations';
|
|
6
6
|
import { type MutationAnnotations } from '../../web-components/mutation-annotations-context';
|
|
7
|
-
import {
|
|
7
|
+
import { buildAnnotationIndex, getMutationAnnotationsProvider } from '../MutationAnnotationsContext';
|
|
8
8
|
|
|
9
9
|
describe('getFilteredMutationCodes', () => {
|
|
10
10
|
it('should filter by displayed segments', () => {
|
|
@@ -129,7 +129,7 @@ describe('getFilteredMutationCodes', () => {
|
|
|
129
129
|
|
|
130
130
|
describe('should filter by annotation', () => {
|
|
131
131
|
const expectFilteredValue = (filterValue: MutationFilter, annotations: MutationAnnotations) => {
|
|
132
|
-
const annotationProvider = getMutationAnnotationsProvider(
|
|
132
|
+
const annotationProvider = getMutationAnnotationsProvider(buildAnnotationIndex(annotations));
|
|
133
133
|
|
|
134
134
|
const result = getFilteredMutationCodes({
|
|
135
135
|
overallMutationData: [someSubstitutionEntry, anotherSubstitutionEntry, someDeletionEntry],
|
|
@@ -94,10 +94,10 @@ function mutationOrAnnotationMatchesTextFilter(
|
|
|
94
94
|
return false;
|
|
95
95
|
}
|
|
96
96
|
return mutationAnnotations.some(
|
|
97
|
-
(
|
|
98
|
-
annotation.description.includes(textFilter) ||
|
|
99
|
-
annotation.name.includes(textFilter) ||
|
|
100
|
-
annotation.symbol.includes(textFilter),
|
|
97
|
+
(resolved) =>
|
|
98
|
+
resolved.annotation.description.includes(textFilter) ||
|
|
99
|
+
resolved.annotation.name.includes(textFilter) ||
|
|
100
|
+
resolved.annotation.symbol.includes(textFilter),
|
|
101
101
|
);
|
|
102
102
|
}
|
|
103
103
|
|
|
@@ -115,5 +115,5 @@ function mutationMatchesAnnotationFilter(
|
|
|
115
115
|
if (mutationAnnotations === undefined || mutationAnnotations.length === 0) {
|
|
116
116
|
return false;
|
|
117
117
|
}
|
|
118
|
-
return mutationAnnotations.some((
|
|
118
|
+
return mutationAnnotations.some((resolved) => annotationNameFilter.has(resolved.annotation.name));
|
|
119
119
|
}
|
|
@@ -45,6 +45,10 @@ export class AppComponent extends LitElement {
|
|
|
45
45
|
/**
|
|
46
46
|
* Supply lists of mutations that are especially relevant for the current organism.
|
|
47
47
|
*
|
|
48
|
+
* Each entry in `nucleotideMutations`, `aminoAcidMutations`, `nucleotidePositions`, and `aminoAcidPositions`
|
|
49
|
+
* can be either a plain string or an object with an optional `name` and `description` that override the
|
|
50
|
+
* group-level values in the annotation popup for that specific mutation or position.
|
|
51
|
+
*
|
|
48
52
|
* Visit https://genspectrum.github.io/dashboard-components/?path=/docs/concepts-mutation-annotations--docs for more information.
|
|
49
53
|
*/
|
|
50
54
|
@provide({ context: mutationAnnotationsContext })
|
|
@@ -53,10 +57,10 @@ export class AppComponent extends LitElement {
|
|
|
53
57
|
name: string;
|
|
54
58
|
description: string;
|
|
55
59
|
symbol: string;
|
|
56
|
-
nucleotideMutations?: string[];
|
|
57
|
-
nucleotidePositions?: string[];
|
|
58
|
-
aminoAcidMutations?: string[];
|
|
59
|
-
aminoAcidPositions?: string[];
|
|
60
|
+
nucleotideMutations?: (string | { mutation: string; name?: string; description?: string })[];
|
|
61
|
+
nucleotidePositions?: (string | { position: string; name?: string; description?: string })[];
|
|
62
|
+
aminoAcidMutations?: (string | { mutation: string; name?: string; description?: string })[];
|
|
63
|
+
aminoAcidPositions?: (string | { position: string; name?: string; description?: string })[];
|
|
60
64
|
}[] = [];
|
|
61
65
|
|
|
62
66
|
/**
|
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
import { createContext } from '@lit/context';
|
|
2
2
|
import z from 'zod';
|
|
3
3
|
|
|
4
|
-
const
|
|
4
|
+
const mutationEntrySchema = z.union([
|
|
5
|
+
z.string(),
|
|
6
|
+
z.object({ mutation: z.string(), name: z.string().optional(), description: z.string().optional() }),
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
const positionEntrySchema = z.union([
|
|
10
|
+
z.string(),
|
|
11
|
+
z.object({ position: z.string(), name: z.string().optional(), description: z.string().optional() }),
|
|
12
|
+
]);
|
|
5
13
|
|
|
6
14
|
const mutationAnnotationSchema = z.object({
|
|
7
15
|
name: z.string(),
|
|
8
16
|
description: z.string(),
|
|
9
17
|
symbol: z.string(),
|
|
10
|
-
nucleotideMutations:
|
|
11
|
-
nucleotidePositions:
|
|
12
|
-
aminoAcidMutations:
|
|
13
|
-
aminoAcidPositions:
|
|
18
|
+
nucleotideMutations: z.array(mutationEntrySchema).optional(),
|
|
19
|
+
nucleotidePositions: z.array(positionEntrySchema).optional(),
|
|
20
|
+
aminoAcidMutations: z.array(mutationEntrySchema).optional(),
|
|
21
|
+
aminoAcidPositions: z.array(positionEntrySchema).optional(),
|
|
14
22
|
});
|
|
15
23
|
export type MutationAnnotation = z.infer<typeof mutationAnnotationSchema>;
|
|
16
24
|
|
|
@@ -39,3 +39,32 @@ The annotation can be applied to specific mutations:
|
|
|
39
39
|
- `nucleotideMutations: [C44T]` matches only the nucleotide mutation `C44T`,
|
|
40
40
|
- `aminoAcidPositions: [S:123]` matches all amino acid mutations that occur on the gene `S` at position `123`
|
|
41
41
|
- If the pathogen has only one segment, one can omit the segment, writing `123` for any mutation at position `123`.
|
|
42
|
+
|
|
43
|
+
## Per-mutation name and description
|
|
44
|
+
|
|
45
|
+
Instead of a plain string, each entry in `nucleotideMutations`, `aminoAcidMutations`, `nucleotidePositions`, and `aminoAcidPositions` can be an object with optional `name` and `description` fields.
|
|
46
|
+
These override the group-level `name` and `description` in the popup for that specific mutation, while the group-level values remain as fallback for entries that don't specify their own.
|
|
47
|
+
|
|
48
|
+
This is useful when grouping mutations under a shared symbol (e.g. all mutations of one drug target) while still showing per-mutation details in the popup:
|
|
49
|
+
|
|
50
|
+
```html
|
|
51
|
+
<gs-app
|
|
52
|
+
lapis="https://your.lapis.url"
|
|
53
|
+
mutationAnnotations="[
|
|
54
|
+
{
|
|
55
|
+
name: '3CLpro inhibitor resistance',
|
|
56
|
+
description: 'Mutations affecting 3CLpro drug binding.',
|
|
57
|
+
symbol: 'c',
|
|
58
|
+
nucleotideMutations: [
|
|
59
|
+
{ mutation: 'ORF1a:T2343C', name: '3CLpro:T31C', description: 'Disrupts nirmatrelvir binding.' },
|
|
60
|
+
{ mutation: 'ORF1a:G2558A', name: '3CLpro:E166K' },
|
|
61
|
+
'ORF1a:C2566T'
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
]"
|
|
65
|
+
>
|
|
66
|
+
{/* children... */}
|
|
67
|
+
</gs-app>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
In this example, clicking `ORF1a:T2343C` shows `3CLpro:T31C` as the title and the specific description, clicking `ORF1a:G2558A` shows `3CLpro:E166K` but falls back to the group description, and clicking `ORF1a:C2566T` falls back to both the group name and description.
|