diff --git a/components/src/preact/components/features-over-time-grid.tsx b/components/src/preact/components/features-over-time-grid.tsx index 2723958a..a618dfa1 100644 --- a/components/src/preact/components/features-over-time-grid.tsx +++ b/components/src/preact/components/features-over-time-grid.tsx @@ -30,7 +30,7 @@ export type CustomColumn = z.infer; export interface FeatureRenderer { asString(value: D): string; renderRowLabel(value: D): JSX.Element; - renderTooltip(value: D, temporal: Temporal, proportionValue: ProportionValue | undefined): JSX.Element; + renderTooltip(value: D, temporal: Temporal, proportionValue: ProportionValue): JSX.Element; } export interface FeaturesOverTimeGridProps { @@ -99,12 +99,20 @@ function FeaturesOverTimeGrid({ ), cell: ({ getValue, row, column, table }) => { - const value = getValue(); + const valueRaw = getValue(); + const value = valueRaw ?? null; const rowIndex = row.index; const columnIndex = column.getIndex(); const numberOfRows = table.getRowModel().rows.length; const numberOfColumns = table.getAllColumns().length; + if (valueRaw === undefined) { + // eslint-disable-next-line no-console -- We want to warn that something might be wrong. + console.error( + `Found undefined value for ${row.original.feature} - ${date.dateString}. This shouldn't happen.`, + ); + } + const tooltip = featureRenderer.renderTooltip(row.original.feature, date, value); return ( diff --git a/components/src/preact/wastewater/mutationsOverTime/computeWastewaterMutationsOverTimeDataPerLocation.spec.ts b/components/src/preact/wastewater/mutationsOverTime/computeWastewaterMutationsOverTimeDataPerLocation.spec.ts index b815a273..b4934670 100644 --- a/components/src/preact/wastewater/mutationsOverTime/computeWastewaterMutationsOverTimeDataPerLocation.spec.ts +++ b/components/src/preact/wastewater/mutationsOverTime/computeWastewaterMutationsOverTimeDataPerLocation.spec.ts @@ -156,4 +156,59 @@ describe('groupMutationDataByLocation', () => { expect(location1Data.getFirstAxisKeys()).to.deep.equal([mutation1, mutation2, mutation3]); }); + + test('should backfill missing mutation-date combinations with explicit null', () => { + const input: WastewaterData = [ + { + location: location1, + date: temporalCache.getYearMonthDay('2025-01-01'), + nucleotideMutationFrequency: [ + { mutation: mutation1, proportion: 0.1 }, + // mutation2 missing for this date + ], + aminoAcidMutationFrequency: [], + }, + { + location: location1, + date: temporalCache.getYearMonthDay('2025-01-02'), + nucleotideMutationFrequency: [ + // mutation1 missing for this date + { mutation: mutation2, proportion: 0.2 }, + ], + aminoAcidMutationFrequency: [], + }, + ]; + + const result = groupMutationDataByLocation(input, 'nucleotide'); + + expect(result).to.have.length(1); + const location1Data = result[0].data; + + // Both mutations should appear in all dates + expect(location1Data.getFirstAxisKeys()).to.deep.equal([mutation1, mutation2]); + expect(location1Data.getSecondAxisKeys()).to.deep.equal([ + temporalCache.getYearMonthDay('2025-01-01'), + temporalCache.getYearMonthDay('2025-01-02'), + ]); + + // Verify backfilled nulls (not undefined) + expect(location1Data.get(mutation1, temporalCache.getYearMonthDay('2025-01-02'))).to.equal(null); + expect(location1Data.get(mutation2, temporalCache.getYearMonthDay('2025-01-01'))).to.equal(null); + + // Verify actual values still present + expect(location1Data.get(mutation1, temporalCache.getYearMonthDay('2025-01-01'))).to.deep.equal({ + type: 'wastewaterValue', + proportion: 0.1, + }); + expect(location1Data.get(mutation2, temporalCache.getYearMonthDay('2025-01-02'))).to.deep.equal({ + type: 'wastewaterValue', + proportion: 0.2, + }); + + // Verify the complete grid with getAsArray + expect(location1Data.getAsArray()).to.deep.equal([ + [{ type: 'wastewaterValue', proportion: 0.1 }, null], + [null, { type: 'wastewaterValue', proportion: 0.2 }], + ]); + }); }); diff --git a/components/src/preact/wastewater/mutationsOverTime/computeWastewaterMutationsOverTimeDataPerLocation.ts b/components/src/preact/wastewater/mutationsOverTime/computeWastewaterMutationsOverTimeDataPerLocation.ts index ba1983bd..28eaba2c 100644 --- a/components/src/preact/wastewater/mutationsOverTime/computeWastewaterMutationsOverTimeDataPerLocation.ts +++ b/components/src/preact/wastewater/mutationsOverTime/computeWastewaterMutationsOverTimeDataPerLocation.ts @@ -21,15 +21,26 @@ export async function computeWastewaterMutationsOverTimeDataPerLocation( export function groupMutationDataByLocation(data: WastewaterData, sequenceType: 'nucleotide' | 'amino acid') { const locationMap = new Map>(); + // Track all unique mutations and dates per location for backfilling + const locationMutations = new Map>(); + const locationDates = new Map>(); + + // First pass: populate sparse data and track all keys for (const row of data) { if (!locationMap.has(row.location)) { locationMap.set(row.location, new BaseMutationOverTimeDataMap()); + locationMutations.set(row.location, new Set()); + locationDates.set(row.location, new Set()); } const map = locationMap.get(row.location)!; + const mutations = locationMutations.get(row.location)!; + const dates = locationDates.get(row.location)!; const mutationFrequencies = sequenceType === 'nucleotide' ? row.nucleotideMutationFrequency : row.aminoAcidMutationFrequency; for (const mutation of mutationFrequencies) { + dates.add(row.date.dateString); + mutations.add(mutation.mutation.code); map.set( mutation.mutation, row.date, @@ -38,6 +49,21 @@ export function groupMutationDataByLocation(data: WastewaterData, sequenceType: } } + // Second pass: backfill missing cells with explicit null + for (const [location, map] of locationMap.entries()) { + const allMutations = Array.from(locationMutations.get(location)!); + const allDates = Array.from(locationDates.get(location)!).map((dateStr) => map.keysSecondAxis.get(dateStr)!); + + for (const mutationCode of allMutations) { + const mutation = map.keysFirstAxis.get(mutationCode)!; + for (const date of allDates) { + if (map.get(mutation, date) === undefined) { + map.set(mutation, date, null); + } + } + } + } + return [...locationMap.entries()].map(([location, data]) => ({ location, data: new SortedMap2d(