From 4a28872281de87e42a7aa01361f5effca52139f3 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 24 Jun 2026 14:26:49 +0200 Subject: [PATCH 1/3] feat(website): show Jaccard index for predefined Nextclade variant mutations (#240) Compute and display a Jaccard index column for mutations loaded from Nextclade tree collections, using the same clinical LAPIS queries as the computed signature mode. When no clinical sequences match the lineage (e.g. no public data available), the column is silently omitted. Co-Authored-By: Claude Sonnet 4.6 --- .../views/wasap/useWasapPageData.ts | 38 +++++- website/src/lapis/getMutations.spec.ts | 109 +++++++++++++++++- website/src/lapis/getMutations.ts | 33 ++++++ 3 files changed, 175 insertions(+), 5 deletions(-) diff --git a/website/src/components/views/wasap/useWasapPageData.ts b/website/src/components/views/wasap/useWasapPageData.ts index 9d69bde1e..5e14c01f0 100644 --- a/website/src/components/views/wasap/useWasapPageData.ts +++ b/website/src/components/views/wasap/useWasapPageData.ts @@ -16,7 +16,7 @@ import { getBackendServiceForClientside } from '../../../backendApi/backendServi import { getCollection } from '../../../covspectrum/getCollection'; import { detailedMutationsToQuery } from '../../../covspectrum/variantConversionUtil'; import { getCladeLineages } from '../../../lapis/getCladeLineages'; -import { getMutations, getMutationsForVariant } from '../../../lapis/getMutations'; +import { getJaccardForMutations, getMutations, getMutationsForVariant } from '../../../lapis/getMutations'; import { parseQuery } from '../../../lapis/parseQuery'; import { validateGenomeOnly } from '../../../util/siloExpressionUtils'; @@ -77,7 +77,7 @@ async function fetchVariantModeData( case 'computed': return fetchVariantComputedModeData(config, analysis); case 'predefined': - return fetchVariantPredefinedModeData(analysis); + return fetchVariantPredefinedModeData(config, analysis); } } @@ -113,7 +113,13 @@ async function fetchVariantComputedModeData( }; } -async function fetchVariantPredefinedModeData(analysis: WasapVariantFilter): Promise { +async function fetchVariantPredefinedModeData( + config: WasapPageConfig, + analysis: WasapVariantFilter, +): Promise { + if (!config.variantAnalysisModeEnabled) { + throw Error("Cannot fetch data, 'variant' mode is not enabled."); + } if (analysis.collectionId === undefined) { throw new Error('No collection selected for predefined variant mode.'); } @@ -140,7 +146,31 @@ async function fetchVariantPredefinedModeData(analysis: WasapVariantFilter): Pro ? (variant.filterObject.nucleotideMutations ?? []) : (variant.filterObject.aminoAcidMutations ?? []); - return { type: 'mutations', displayMutations: mutations }; + const jaccardByMutation = await getJaccardForMutations( + config.clinicalLapis.lapisBaseUrl, + analysis.sequenceType, + { [config.clinicalLapis.lineageField]: collection.name }, + getLapisFilterForTimeFrame(analysis.timeFrame, config.clinicalLapis.dateField), + ); + + if (jaccardByMutation.size === 0) { + return { type: 'mutations', displayMutations: mutations }; + } + + return { + type: 'mutations', + displayMutations: mutations, + customColumns: [ + { + header: 'Jaccard index', + values: Object.fromEntries( + mutations + .filter((m) => jaccardByMutation.has(m)) + .map((m) => [m, jaccardByMutation.get(m)!.toPrecision(2)]), + ), + }, + ], + }; } function fetchResistanceModeData( diff --git a/website/src/lapis/getMutations.spec.ts b/website/src/lapis/getMutations.spec.ts index 394b30edd..55c919511 100644 --- a/website/src/lapis/getMutations.spec.ts +++ b/website/src/lapis/getMutations.spec.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, test } from 'vitest'; -import { getMutations, getMutationsForVariant } from './getMutations.ts'; +import { getJaccardForMutations, getMutations, getMutationsForVariant } from './getMutations.ts'; import { DUMMY_LAPIS_URL } from '../../routeMocker.ts'; import { astroApiRouteMocker, lapisRouteMocker } from '../../vitest.setup.ts'; @@ -85,6 +85,113 @@ describe('getMutations', () => { }); }); +describe('getJaccardForMutations', () => { + beforeEach(() => { + astroApiRouteMocker.mockLog(); + }); + + test('should return Jaccard indices for mutations observed in lineage', async () => { + // Variant A.1 has 20 sequences in clinical data. + // A1C: in 10 A.1 sequences, 40 total -> Jaccard = 10/(20+40-10) = 10/50 = 0.2 + // A2C: in 20 A.1 sequences, 20 total -> Jaccard = 20/(20+20-20) = 20/20 = 1.0 + lapisRouteMocker.mockPostAggregated({ lineage: 'A.1' }, { data: [{ count: 20 }] }); + lapisRouteMocker.mockPostNucleotideMutationsMulti([ + { + body: { minProportion: 0, lineage: 'A.1' }, + response: { + data: [ + { mutation: 'A1C', count: 10 }, + { mutation: 'A2C', count: 20 }, + ], + }, + }, + { + body: { minProportion: 0 }, + response: { + data: [ + { mutation: 'A1C', count: 40 }, + { mutation: 'A2C', count: 20 }, + ], + }, + }, + ]); + + const result = await getJaccardForMutations(DUMMY_LAPIS_URL, 'nucleotide', { lineage: 'A.1' }, undefined); + + expect(result.get('A1C')).toBeCloseTo(0.2); + expect(result.get('A2C')).toBe(1); + }); + + test('should return empty map when no clinical sequences match the lineage', async () => { + lapisRouteMocker.mockPostAggregated({ lineage: 'A.99' }, { data: [{ count: 0 }] }); + lapisRouteMocker.mockPostNucleotideMutationsMulti([ + { + body: { minProportion: 0, lineage: 'A.99' }, + response: { data: [] }, + }, + { + body: { minProportion: 0 }, + response: { data: [{ mutation: 'A1C', count: 40 }] }, + }, + ]); + + const result = await getJaccardForMutations(DUMMY_LAPIS_URL, 'nucleotide', { lineage: 'A.99' }, undefined); + + expect(result.size).toBe(0); + }); + + test('should not include mutations absent from the lineage', async () => { + // A1C is in the clinical database but not in lineage A.1 sequences. + lapisRouteMocker.mockPostAggregated({ lineage: 'A.1' }, { data: [{ count: 30 }] }); + lapisRouteMocker.mockPostNucleotideMutationsMulti([ + { + body: { minProportion: 0, lineage: 'A.1' }, + response: { data: [{ mutation: 'A2C', count: 30 }] }, + }, + { + body: { minProportion: 0 }, + response: { + data: [ + { mutation: 'A1C', count: 50 }, + { mutation: 'A2C', count: 30 }, + ], + }, + }, + ]); + + const result = await getJaccardForMutations(DUMMY_LAPIS_URL, 'nucleotide', { lineage: 'A.1' }, undefined); + + expect(result.has('A1C')).toBe(false); + expect(result.get('A2C')).toBe(1); + }); + + test('should apply date filter correctly', async () => { + lapisRouteMocker.mockPostAggregated( + { lineage: 'A.1', dateFrom: '2025-01-01' }, + { data: [{ count: 10 }] }, + ); + lapisRouteMocker.mockPostNucleotideMutationsMulti([ + { + body: { minProportion: 0, lineage: 'A.1', dateFrom: '2025-01-01' }, + response: { data: [{ mutation: 'A1C', count: 10 }] }, + }, + { + body: { minProportion: 0, dateFrom: '2025-01-01' }, + response: { data: [{ mutation: 'A1C', count: 10 }] }, + }, + ]); + + const result = await getJaccardForMutations( + DUMMY_LAPIS_URL, + 'nucleotide', + { lineage: 'A.1' }, + { dateFrom: '2025-01-01' }, + ); + + expect(result.get('A1C')).toBe(1); + }); +}); + describe('getMutationsForVariant', () => { beforeEach(() => { astroApiRouteMocker.mockLog(); diff --git a/website/src/lapis/getMutations.ts b/website/src/lapis/getMutations.ts index 27b7ffe2a..343cf2c4d 100644 --- a/website/src/lapis/getMutations.ts +++ b/website/src/lapis/getMutations.ts @@ -41,6 +41,39 @@ export async function getMutations( ); } +/** + * Returns a map from mutation code to Jaccard index for all mutations observed in clinical + * sequences belonging to the given lineage. Mutations not observed in the lineage are absent + * from the map. Returns an empty map when no clinical sequences match the lineage filter. + * + * Use this to annotate a pre-defined list of mutations with clinical Jaccard scores without + * applying any proportion/count/threshold filtering. + */ +export async function getJaccardForMutations( + lapisUrl: string, + mutationType: SequenceType, + lineageFilter: LapisFilter, + dateFilter: LapisFilter | undefined, +): Promise> { + return Promise.all([ + getMutationsInternal(lapisUrl, mutationType, { ...lineageFilter, ...dateFilter }, 0), + getMutationsInternal(lapisUrl, mutationType, dateFilter, 0).then((r) => + Object.fromEntries(r.map((item) => [item.mutation, item.count])), + ), + getTotalCount(lapisUrl, { ...lineageFilter, ...dateFilter }), + ]).then(([intersectionCounts, totalCounts, variantCount]) => { + if (variantCount === 0) { + return new Map(); + } + return new Map( + intersectionCounts.map(({ mutation, count }) => [ + mutation, + count / (variantCount + (totalCounts[mutation] ?? count) - count), + ]), + ); + }); +} + /** * Returns the list of mutations that are defining this variant, based on the given parameters. * The result also includes the Jaccard index for every mutation. From 1b814e0701e4703377149259df3023391c0b6707 Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Wed, 24 Jun 2026 14:34:39 +0200 Subject: [PATCH 2/3] format --- website/src/components/views/wasap/useWasapPageData.ts | 1 + website/src/lapis/getMutations.spec.ts | 5 +---- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/website/src/components/views/wasap/useWasapPageData.ts b/website/src/components/views/wasap/useWasapPageData.ts index 5e14c01f0..31d3c6d09 100644 --- a/website/src/components/views/wasap/useWasapPageData.ts +++ b/website/src/components/views/wasap/useWasapPageData.ts @@ -166,6 +166,7 @@ async function fetchVariantPredefinedModeData( values: Object.fromEntries( mutations .filter((m) => jaccardByMutation.has(m)) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion .map((m) => [m, jaccardByMutation.get(m)!.toPrecision(2)]), ), }, diff --git a/website/src/lapis/getMutations.spec.ts b/website/src/lapis/getMutations.spec.ts index 55c919511..26e64b369 100644 --- a/website/src/lapis/getMutations.spec.ts +++ b/website/src/lapis/getMutations.spec.ts @@ -166,10 +166,7 @@ describe('getJaccardForMutations', () => { }); test('should apply date filter correctly', async () => { - lapisRouteMocker.mockPostAggregated( - { lineage: 'A.1', dateFrom: '2025-01-01' }, - { data: [{ count: 10 }] }, - ); + lapisRouteMocker.mockPostAggregated({ lineage: 'A.1', dateFrom: '2025-01-01' }, { data: [{ count: 10 }] }); lapisRouteMocker.mockPostNucleotideMutationsMulti([ { body: { minProportion: 0, lineage: 'A.1', dateFrom: '2025-01-01' }, From ee9c3b77f5ba42f0580e77d8098fc593276c2d1e Mon Sep 17 00:00:00 2001 From: Felix Hennig Date: Thu, 25 Jun 2026 15:51:08 +0200 Subject: [PATCH 3/3] feat(website): apply minJaccard threshold to predefined variant mode Co-Authored-By: Claude Sonnet 4.6 --- .../wasap/filters/VariantExplorerFilter.tsx | 8 ++++++++ website/src/components/views/wasap/useWasapPageData.ts | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/website/src/components/pageStateSelectors/wasap/filters/VariantExplorerFilter.tsx b/website/src/components/pageStateSelectors/wasap/filters/VariantExplorerFilter.tsx index 6739cf1b5..22e7eef6e 100644 --- a/website/src/components/pageStateSelectors/wasap/filters/VariantExplorerFilter.tsx +++ b/website/src/components/pageStateSelectors/wasap/filters/VariantExplorerFilter.tsx @@ -178,6 +178,14 @@ function PredefinedSignature({ + setPageState({ ...pageState, minJaccard: v })} + /> ); } diff --git a/website/src/components/views/wasap/useWasapPageData.ts b/website/src/components/views/wasap/useWasapPageData.ts index 31d3c6d09..c5035dba1 100644 --- a/website/src/components/views/wasap/useWasapPageData.ts +++ b/website/src/components/views/wasap/useWasapPageData.ts @@ -159,13 +159,13 @@ async function fetchVariantPredefinedModeData( return { type: 'mutations', - displayMutations: mutations, + displayMutations: mutations.filter((m) => (jaccardByMutation.get(m) ?? 0) >= analysis.minJaccard), customColumns: [ { header: 'Jaccard index', values: Object.fromEntries( mutations - .filter((m) => jaccardByMutation.has(m)) + .filter((m) => jaccardByMutation.has(m) && (jaccardByMutation.get(m) ?? 0) >= analysis.minJaccard) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion .map((m) => [m, jaccardByMutation.get(m)!.toPrecision(2)]), ),