diff --git a/eslint.config.mjs b/eslint.config.mjs index 0d43ce2..0c1d7fd 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -35,7 +35,7 @@ export default [ /* ...then apply your custom tweaks */ '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-explicit-any': 'error', /* Security: flag unsafe DOM manipulation */ 'no-unsanitized/method': 'error', diff --git a/src/__spec__/filter-config.spec.ts b/src/__spec__/filter-config.spec.ts index 47dd1e7..69507e7 100644 --- a/src/__spec__/filter-config.spec.ts +++ b/src/__spec__/filter-config.spec.ts @@ -5,93 +5,73 @@ import { getFilteredVariants, VariantsForFilter, } from '../filter-config'; +import { TransformedVariant } from '../adapters/variation-adapter'; -const transformedVariantPositions = [ +const makeVariant = (overrides: Partial): TransformedVariant => + ({ + accession: 'A', + variant: 'A', + start: 1, + xrefNames: [], + hasPredictions: false, + consequenceType: 'missense', + type: 'VARIANT', + begin: '1', + end: '1', + xrefs: [], + cytogeneticBand: '', + locations: [], + somaticStatus: 0, + sourceType: 'uniprot', + wildType: 'A', + ...overrides, + } as TransformedVariant); + +const transformedVariantPositions: VariantsForFilter = [ { variants: [ - { + makeVariant({ accession: 'A', - begin: 1, - end: 1, + begin: '1', + end: '1', start: 1, - tooltipContent: '', - sourceType: 'source', variant: 'V', - protvistaFeatureId: 'id1', - xrefNames: [], - type: 'VARIANT', - wildType: 'A', - alternativeSequence: 'V', - consequenceType: 'disease', clinicalSignificances: [ { - type: 'Variant of uncertain significance', - sources: ['Ensembl'], + type: 'Variant of uncertain significance' as never, + sources: [], }, ], - xrefs: [], - hasPredictions: false, - }, - { + }), + makeVariant({ accession: 'B', - begin: 1, - end: 1, + begin: '1', + end: '1', start: 1, - tooltipContent: '', - sourceType: 'source', variant: 'D', - protvistaFeatureId: 'id2', - xrefNames: [], - type: 'VARIANT', - wildType: 'A', - alternativeSequence: 'D', - consequenceType: 'disease', - xrefs: [], - hasPredictions: false, - }, + }), ], }, { variants: [ - { + makeVariant({ accession: 'C', - begin: 2, - end: 2, + begin: '2', + end: '2', start: 2, - tooltipContent: '', - sourceType: 'source', variant: 'V', - protvistaFeatureId: 'id1', - xrefNames: [], - type: 'VARIANT', - wildType: 'A', - alternativeSequence: 'V', - consequenceType: 'disease', - xrefs: [], - hasPredictions: false, - }, + }), ], }, { variants: [ - { + makeVariant({ accession: 'D', - begin: 3, - end: 3, + begin: '3', + end: '3', start: 3, - tooltipContent: '', - sourceType: 'source', variant: 'V', - protvistaFeatureId: 'id1', - xrefNames: [], - type: 'VARIANT', - wildType: 'A', - alternativeSequence: 'V', - consequenceType: 'disease', - siftScore: 0.5, - xrefs: [], - hasPredictions: false, - }, + }), ], }, ]; @@ -99,47 +79,33 @@ const transformedVariantPositions = [ describe('Variation filter config', () => { test('it should filter according to the callback function', () => { const filteredVariants = getFilteredVariants( - transformedVariantPositions as VariantsForFilter, + transformedVariantPositions, (variant) => variant.accession === 'A' ); expect(filteredVariants).toEqual([ - { - variants: [transformedVariantPositions[0].variants[0]], - }, - { - variants: [], - }, - { - variants: [], - }, + { variants: [transformedVariantPositions[0].variants[0]] }, + { variants: [] }, + { variants: [] }, ]); }); test('it should get the right colour for disease', () => { - const firstVariant = colorConfig( - transformedVariantPositions[0].variants[0] - ); - expect(firstVariant).toEqual('#009e73'); + const result = colorConfig(transformedVariantPositions[0].variants[0]); + expect(result).toEqual('#009e73'); }); test('it should get the right colour for non disease', () => { - const secondVariant = colorConfig( - transformedVariantPositions[0].variants[1] - ); - expect(secondVariant).toEqual('#009e73'); + const result = colorConfig(transformedVariantPositions[0].variants[1]); + expect(result).toEqual('#009e73'); }); test('it should get the right colour for other', () => { - const thirdVariant = colorConfig( - transformedVariantPositions?.[1].variants[0] - ); - expect(thirdVariant).toEqual('#009e73'); + const result = colorConfig(transformedVariantPositions[1].variants[0]); + expect(result).toEqual('#009e73'); }); test('it should get the right colour for predicted', () => { - const thirdVariant = colorConfig( - transformedVariantPositions[2].variants[0] - ); - expect(thirdVariant).toEqual('#009e73'); + const result = colorConfig(transformedVariantPositions[2].variants[0]); + expect(result).toEqual('#009e73'); }); }); diff --git a/src/adapters/alphafold-confidence-adapter.ts b/src/adapters/alphafold-confidence-adapter.ts index 20e3d2f..7583908 100644 --- a/src/adapters/alphafold-confidence-adapter.ts +++ b/src/adapters/alphafold-confidence-adapter.ts @@ -11,12 +11,13 @@ const getConfidenceURLFromPayload = (af: AlphaFoldPayload[number]) => const loadConfidence = async ( url: string -): Promise => { +): Promise => { try { const payload = await fetch(url); return payload.json(); } catch (e) { console.error('Could not load AlphaFold confidence', e); + return undefined; } }; diff --git a/src/adapters/alphamissense-heatmap-adapter.ts b/src/adapters/alphamissense-heatmap-adapter.ts index 204184b..1b36a96 100644 --- a/src/adapters/alphamissense-heatmap-adapter.ts +++ b/src/adapters/alphamissense-heatmap-adapter.ts @@ -5,15 +5,22 @@ import { rowSplitter, } from './alphamissense-pathogenicity-adapter'; -const parseCSV = (rawText: string): Array> => { - const data = []; +type HeatmapRow = { + xValue: number; + yValue: string; + score: number; +}; + +const parseCSV = (rawText: string): HeatmapRow[] => { + const data: HeatmapRow[] = []; for (const [i, row] of rawText.split(rowSplitter).entries()) { if (i === 0 || !row) { continue; } - const [, , positionString, mutated, pathogenicityScore] = - row.match(cellSplitter); + const match = row.match(cellSplitter); + if (!match) continue; + const [, , positionString, mutated, pathogenicityScore] = match; data.push({ xValue: +positionString, @@ -27,13 +34,14 @@ const parseCSV = (rawText: string): Array> => { // Load and parse const loadAndParseAnnotations = async ( url: string -): Promise>> => { +): Promise => { try { const payload = await fetch(url); const rawCSV = await payload.text(); return parseCSV(rawCSV); } catch (e) { console.error('Could not load AlphaMissense pathogenicity', e); + return undefined; } }; @@ -52,9 +60,9 @@ const transformData = async ( protein.sequence.sequence === sequence && amAnnotationsUrl ); if (alphaFoldSequenceMatch.length === 1) { - const heatmapData = await loadAndParseAnnotations( - alphaFoldSequenceMatch[0].amAnnotationsUrl - ); + const url = alphaFoldSequenceMatch[0].amAnnotationsUrl; + if (!url) return undefined; + const heatmapData = await loadAndParseAnnotations(url); return heatmapData; } else if (alphaFoldSequenceMatch.length > 1) { console.warn( diff --git a/src/adapters/alphamissense-pathogenicity-adapter.ts b/src/adapters/alphamissense-pathogenicity-adapter.ts index b54f65d..4976bd6 100644 --- a/src/adapters/alphamissense-pathogenicity-adapter.ts +++ b/src/adapters/alphamissense-pathogenicity-adapter.ts @@ -25,7 +25,7 @@ const pathogenicityCategories = [ { min: pathogenic, max: certainlyPathogenic, code: 'P' }, ]; -const getPathogenicityCode = (score) => { +const getPathogenicityCode = (score: number): string | undefined => { for (const { min, max, code } of pathogenicityCategories) { if (score >= min && score < max) { return code; @@ -93,13 +93,14 @@ const parseCSV = (rawText: string): string => { }; // Load and parse -const loadAndParseAnnotations = async (url: string): Promise => { +const loadAndParseAnnotations = async (url: string): Promise => { try { const payload = await fetch(url); const rawCSV = await payload.text(); return parseCSV(rawCSV); } catch (e) { console.error('Could not load AlphaMissense pathogenicity score', e); + return undefined; } }; @@ -118,9 +119,9 @@ const transformData = async ( protein.sequence.sequence === sequence && amAnnotationsUrl ); if (alphaFoldSequenceMatch.length === 1) { - const heatmapData = await loadAndParseAnnotations( - alphaFoldSequenceMatch[0].amAnnotationsUrl - ); + const url = alphaFoldSequenceMatch[0].amAnnotationsUrl; + if (!url) return undefined; + const heatmapData = await loadAndParseAnnotations(url); return heatmapData; } else if (alphaFoldSequenceMatch.length > 1) { console.warn( diff --git a/src/adapters/feature-adapter.ts b/src/adapters/feature-adapter.ts index 63bb552..663f597 100644 --- a/src/adapters/feature-adapter.ts +++ b/src/adapters/feature-adapter.ts @@ -1,8 +1,8 @@ import { renameProperties } from '../utils'; import formatTooltip from '../tooltips/feature-tooltip'; -const transformData = (data) => { - let transformedData = []; +const transformData = (data: { features?: Record[] }) => { + let transformedData: Record[] = []; const { features } = data; if (features && features.length > 0) { transformedData = features.map((feature) => { diff --git a/src/adapters/proteomics-adapter.ts b/src/adapters/proteomics-adapter.ts index 7090fdb..1ec3d8e 100644 --- a/src/adapters/proteomics-adapter.ts +++ b/src/adapters/proteomics-adapter.ts @@ -1,18 +1,28 @@ import { renameProperties } from '../utils'; -import formatTooltip from '../tooltips/feature-tooltip'; +import formatTooltip, { TooltipFeature } from '../tooltips/feature-tooltip'; -const proteomicsTrackProperties = (feature, taxId) => { +type ProteomicsFeature = TooltipFeature & { + unique: boolean; + ptms?: { name: string; position: number; sources: string[]; dbReferences: { id: string; properties: Record }[] }[]; +}; + +type ProteomicsData = { + features: ProteomicsFeature[]; + taxid: number; +}; + +const proteomicsTrackProperties = (feature: ProteomicsFeature, taxId: number) => { return { category: 'PROTEOMICS', type: feature.unique ? 'unique' : 'non_unique', - tooltipContent: formatTooltip(feature, taxId), + tooltipContent: formatTooltip(feature, String(taxId)), }; }; -const transformData = (data) => { - let adaptedData = []; +const transformData = (data: ProteomicsData) => { + let adaptedData: (ProteomicsFeature & { start?: number })[] = []; - if (data && data.length !== 0) { + if (data && data.features && data.features.length !== 0) { adaptedData = data.features.map((feature) => { feature.residuesToHighlight = feature.ptms?.map((ptm) => ({ name: ptm.name, @@ -26,7 +36,7 @@ const transformData = (data) => { ); }); - adaptedData = renameProperties(adaptedData); + adaptedData = renameProperties(adaptedData) as typeof adaptedData; } return adaptedData; }; diff --git a/src/adapters/ptm-exchange-adapter.ts b/src/adapters/ptm-exchange-adapter.ts index 7ff1e37..dd32086 100644 --- a/src/adapters/ptm-exchange-adapter.ts +++ b/src/adapters/ptm-exchange-adapter.ts @@ -97,10 +97,13 @@ const convertPtmExchangePtms = ( `MOD_RES_LS ${absolutePosition}-${absolutePosition}`, groupedPtms, aa, - confidenceScore + confidenceScore ?? '' ), color: - (confidenceScore && ConfidenceScoreColors[confidenceScore]) || 'black', + (confidenceScore !== null && + confidenceScore in ConfidenceScoreColors && + ConfidenceScoreColors[confidenceScore as keyof typeof ConfidenceScoreColors]) || + 'black', }; }); }; diff --git a/src/adapters/structure-adapter.ts b/src/adapters/structure-adapter.ts index 91daa33..9c10f67 100644 --- a/src/adapters/structure-adapter.ts +++ b/src/adapters/structure-adapter.ts @@ -3,21 +3,21 @@ import formatTooltip from '../tooltips/structure-tooltip'; const featureType = 'PDBE_COVER'; const featureCategory = 'STRUCTURE_COVERAGE'; -const capitalizeFirstLetter = (word) => { +const capitalizeFirstLetter = (word: string): string => { return word.charAt(0).toUpperCase() + word.slice(1); }; -const getDescription = (properties) => { +const getDescription = (properties: Record): string => { return Object.keys(properties).reduce( (accumulator, propertyKey) => `${accumulator}${capitalizeFirstLetter(propertyKey)}: ${ - properties[propertyKey] + properties[propertyKey] ?? '' }. `, '' ); }; -const parseChainString = (value) => { +const parseChainString = (value: string): { start: number; end: number } => { const posEqual = value.indexOf('='); const posDash = value.indexOf('-'); if (posEqual === -1 || posDash === -1) { @@ -29,14 +29,38 @@ const parseChainString = (value) => { }; }; +type DbReference = { + type: string; + id: string; + properties?: Record; +}; + +type UniProtEntry = { + dbReferences: DbReference[]; +}; + +export type StructureFeature = { + type: string; + category: string; + structures: { + description: string; + start: number; + end: number; + source: { id: string; url: string }; + }[]; + start: number; + end: number; + tooltipContent?: string; +}; + // Iterate over references and extract chain start and end -export const getAllFeatureStructures = (data) => { +export const getAllFeatureStructures = (data: UniProtEntry): StructureFeature[] => { return data.dbReferences .filter((reference) => { return reference.type === 'PDB'; }) .map((structureReference) => { - const parsedChain = structureReference.properties.chains + const parsedChain = structureReference.properties?.chains ? parseChainString(structureReference.properties.chains) : { start: 0, end: 0 }; return { @@ -44,7 +68,9 @@ export const getAllFeatureStructures = (data) => { category: featureCategory, structures: [ { - description: getDescription(structureReference.properties), + description: structureReference.properties + ? getDescription(structureReference.properties) + : '', start: parsedChain.start, end: parsedChain.end, source: { @@ -59,13 +85,13 @@ export const getAllFeatureStructures = (data) => { }); }; -export const mergeOverlappingIntervals = (structures) => { +export const mergeOverlappingIntervals = (structures: StructureFeature[]): StructureFeature[] => { if (!structures || structures.length <= 0) { return []; } // Sort by start position const sortedStructures = structures.sort((a, b) => a.start - b.start); - const mergedIntervals = []; + const mergedIntervals: StructureFeature[] = []; sortedStructures.forEach((structure) => { const lastItem = mergedIntervals[mergedIntervals.length - 1]; if ( @@ -88,9 +114,9 @@ export const mergeOverlappingIntervals = (structures) => { return mergedIntervals; }; -const transformData = (data) => { - let transformedData = []; - if (data && data.length !== 0) { +const transformData = (data: UniProtEntry): StructureFeature[] => { + let transformedData: StructureFeature[] = []; + if (data && data.dbReferences) { const allFeatureStructures = getAllFeatureStructures(data); transformedData = mergeOverlappingIntervals(allFeatureStructures); diff --git a/src/adapters/types/interpro.ts b/src/adapters/types/interpro.ts index f563eed..ad9190c 100644 --- a/src/adapters/types/interpro.ts +++ b/src/adapters/types/interpro.ts @@ -64,7 +64,7 @@ export type TransformedInterPro = { locations: EntryProteinLocation[]; start: string | number; end: string | number; - color: any; + color: string; tooltipContent: string; length: number; accession: string; diff --git a/src/adapters/variation-adapter.ts b/src/adapters/variation-adapter.ts index c57d943..943904c 100644 --- a/src/adapters/variation-adapter.ts +++ b/src/adapters/variation-adapter.ts @@ -24,15 +24,15 @@ const transformData = ( ): { sequence: string; variants: TransformedVariant[]; -} => { +} | null => { const { sequence, features } = data; const variants = features.map((variant) => ({ ...variant, - accession: variant.genomicLocation?.join(', '), + accession: variant.genomicLocation?.join(', ') ?? '', variant: variant.alternativeSequence || AminoAcid.Empty, start: +variant.begin, xrefNames: getSourceType(variant.xrefs, variant.sourceType), - hasPredictions: variant.predictions && variant.predictions.length > 0, + hasPredictions: !!(variant.predictions && variant.predictions.length > 0), tooltipContent: formatTooltip(variant), })); if (!variants) return null; diff --git a/src/adapters/variation-graph-adapter.ts b/src/adapters/variation-graph-adapter.ts index 8c02f12..4b234c6 100644 --- a/src/adapters/variation-graph-adapter.ts +++ b/src/adapters/variation-graph-adapter.ts @@ -1,6 +1,8 @@ -const transformData = (data) => { +import { ProteinsAPIVariation, Variant } from '@nightingale-elements/nightingale-variation'; + +const transformData = (data: ProteinsAPIVariation) => { if (data.sequence && data.features.length) { - const variants = data.features.map((variant) => ({ + const variants = (data.features as Variant[]).map((variant) => ({ ...variant, accession: variant.genomicLocation?.join(', '), start: variant.begin, @@ -17,8 +19,8 @@ const transformData = (data) => { total[index] += 1; if (!association) continue; - const hasDisease = association.find( - (association) => association.disease === true + const hasDisease = (association as { disease?: boolean }[]).find( + (assoc) => assoc.disease === true ); if (hasDisease) diseaseTotal[index] += 1; } diff --git a/src/filter-config.ts b/src/filter-config.ts index 77b9dae..6976e8e 100644 --- a/src/filter-config.ts +++ b/src/filter-config.ts @@ -1,5 +1,6 @@ import { VariationDatum } from '@nightingale-elements/nightingale-variation'; -import { ClinicalSignificance } from '@nightingale-elements/nightingale-variation'; +import { ClinicalSignificance, Variant } from '@nightingale-elements/nightingale-variation'; +import { TransformedVariant } from './adapters/variation-adapter'; const scaleColors = { UPDiseaseColor: '#990000', @@ -21,12 +22,12 @@ const significanceMatches = ( }); export type VariantsForFilter = { - variants: VariationDatum[]; + variants: TransformedVariant[]; }[]; export const getFilteredVariants = ( variants: VariantsForFilter, - callbackFilter: (variantPos: VariationDatum) => void + callbackFilter: (variantPos: TransformedVariant) => void ) => variants.map((variant) => { const matchingVariants = variant.variants.filter((variantPos) => @@ -39,14 +40,14 @@ export const getFilteredVariants = ( }); const filterPredicates = { - disease: (variantPos) => + disease: (variantPos: TransformedVariant) => variantPos.association?.some((association) => association.disease), - predicted: (variantPos) => variantPos.hasPredictions, - nonDisease: (variantPos) => + predicted: (variantPos: TransformedVariant) => variantPos.hasPredictions, + nonDisease: (variantPos: TransformedVariant) => variantPos.association?.some( (association) => association.disease === false ), - uncertain: (variantPos) => + uncertain: (variantPos: TransformedVariant) => (typeof variantPos.clinicalSignificances === 'undefined' && !variantPos.hasPredictions) || (variantPos.clinicalSignificances && @@ -54,15 +55,15 @@ const filterPredicates = { variantPos.clinicalSignificances, consequences.uncertain )), - UniProt: (variantPos) => + UniProt: (variantPos: TransformedVariant) => variantPos.xrefNames && (variantPos.xrefNames.includes('uniprot') || variantPos.xrefNames.includes('UniProt')), - ClinVar: (variantPos) => + ClinVar: (variantPos: TransformedVariant) => variantPos.xrefNames && (variantPos.xrefNames.includes('ClinVar') || variantPos.xrefNames.includes('clinvar')), - LSS: (variantPos) => + LSS: (variantPos: TransformedVariant) => variantPos.sourceType === 'large_scale_study' || variantPos.sourceType === 'mixed', }; @@ -170,7 +171,7 @@ const filterConfig = [ const countVariantsForFilter = ( filterName: 'disease' | 'nonDisease' | 'uncertain' | 'predicted', - variant: VariationDatum + variant: TransformedVariant ) => { const variantWrapper: VariantsForFilter = [{ variants: [variant] }]; const filter = filterConfig.find((filter) => filter.name === filterName); @@ -180,7 +181,7 @@ const countVariantsForFilter = ( return false; }; -export const colorConfig = (variant: any) => { +export const colorConfig = (variant: TransformedVariant) => { if (countVariantsForFilter('disease', variant)) { return scaleColors.UPDiseaseColor; } else if (countVariantsForFilter('nonDisease', variant)) { diff --git a/src/protvista-uniprot-structure.ts b/src/protvista-uniprot-structure.ts index d64eca3..7902a76 100644 --- a/src/protvista-uniprot-structure.ts +++ b/src/protvista-uniprot-structure.ts @@ -210,7 +210,7 @@ const processAFData = ( ${isoformMatch.isoformId} ${isoformMatch.sequence === canonicalSequence ? '(Canonical)' : ''} ` - : null; + : undefined; return { id: d.modelEntityId, @@ -518,7 +518,7 @@ class ProtvistaUniprotStructure extends LitElement { if (this.isoforms && rawData[alphaFoldUrl]?.length) { // Include isoforms that are provided in the UniProt isoforms mapping and ignore the rest from AF payload that are out of sync with UniProt const alphaFoldSequenceMatches = rawData[alphaFoldUrl]?.filter( - ({ sequence: afSequence }) => + ({ sequence: afSequence }: { sequence: string }) => this.isoforms?.some(({ sequence }) => afSequence === sequence) ); @@ -533,14 +533,14 @@ class ProtvistaUniprotStructure extends LitElement { } else { // Check if AF sequence matches UniProt sequence const alphaFoldSequenceMatch = rawData[alphaFoldUrl]?.filter( - ({ sequence: afSequence }) => + ({ sequence: afSequence }: { sequence: string }) => rawData[pdbUrl]?.sequence?.value === afSequence || this.sequence === afSequence ); if (alphaFoldSequenceMatch?.length) { afData = processAFData(alphaFoldSequenceMatch); this.alphamissenseAvailable = alphaFoldSequenceMatch.some( - (data) => data.amAnnotationsUrl + (data: { amAnnotationsUrl?: string }) => data.amAnnotationsUrl ); } } @@ -609,7 +609,7 @@ class ProtvistaUniprotStructure extends LitElement { this.checksum || (providersFrom3DBeacons.includes(source) && !afPrediction) ) { - this.modelUrl = downloadUrl; + this.modelUrl = downloadUrl ?? ''; // Reset the rest this.structureId = undefined; this.metaInfo = undefined; diff --git a/src/protvista-uniprot.ts b/src/protvista-uniprot.ts index b506791..772c7d6 100644 --- a/src/protvista-uniprot.ts +++ b/src/protvista-uniprot.ts @@ -46,15 +46,30 @@ import config, { } from './config'; import { TransformedInterPro } from './adapters/types/interpro'; +import { StructureFeature } from './adapters/structure-adapter'; + +/** Union of all possible per-track payload shapes stored in this.data */ +type TrackPayload = + | Record[] + | { sequence: string; variants: TransformedVariant[] } + | { sequence: string; variants: TransformedVariant[] } & Record + | TransformedInterPro + | StructureFeature[] + | { variants?: TransformedVariant[] } + | string + | null + | undefined; import loaderIcon from './icons/spinner.svg'; import protvistaStyles from './styles/protvista-styles'; import loaderStyles from './styles/loader-styles'; // Heterogeneous adapter map — each adapter has its own signature and return -// shape. Typed loosely here so the .apply() dispatch below doesn't try to -// reconcile the union of all signatures at the call site. -const adapters: Record any> = { +// shape. A cast is required here because TypeScript's function parameter +// contravariance prevents assigning (data: SpecificType) => Result to +// (...args: unknown[]) => unknown. This is an intentional escape hatch; +// the per-adapter types are enforced at the adapter level. +const adapters = { 'feature-adapter': featureAdapter, 'interpro-adapter': interproAdapter, 'proteomics-adapter': proteomicsAdapter, @@ -74,7 +89,7 @@ type NightingaleEvent = Event & { displaystart?: number; displayend?: number; eventType?: 'click' | 'mouseover' | 'mouseout' | 'reset'; - feature?: any; + feature?: unknown; coords?: [number, number]; }; }; @@ -85,8 +100,8 @@ class ProtvistaUniprot extends LitElement { private nostructure: boolean; private hasData: boolean; private loading: boolean; - private data: { [key: string]: any }; - private rawData: { [key: string]: any }; + private data: Record = {}; + private rawData: Record = {}; private displayCoordinates: { start?: number; end?: number } = {}; private suspend?: boolean; private accession?: string; @@ -103,8 +118,6 @@ class ProtvistaUniprot extends LitElement { this.nostructure = false; this.hasData = false; this.loading = true; - this.data = {}; - this.rawData = {}; this.displayCoordinates = {}; this.transformedVariants = { sequence: '', variants: [] }; this.addStyles(); @@ -160,7 +173,13 @@ class ProtvistaUniprot extends LitElement { // Some endpoints return empty arrays, while most fail 🙄 this.hasData = this.hasData || - Object.values(this.rawData).some((d) => !!d?.features?.length); + Object.values(this.rawData).some((d) => { + if (d && typeof d === 'object' && 'features' in d) { + const features = (d as { features?: unknown[] }).features; + return Array.isArray(features) && features.length > 0; + } + return false; + }); // Now iterate over tracks and categories, transforming the data // and assigning it as adequate @@ -175,18 +194,18 @@ class ProtvistaUniprot extends LitElement { if ( !trackData || - (adapter === 'variation-adapter' && trackData[0].length === 0) + (adapter === 'variation-adapter' && Array.isArray(trackData[0]) && trackData[0].length === 0) ) { return; } // 1. Convert data let transformedData = adapter - ? await adapters[adapter].apply(null, trackData) + ? await (adapters as Record unknown>)[adapter].apply(null, trackData) : trackData; if (adapter === 'interpro-adapter') { - const representativeDomains = []; + const representativeDomains: TransformedInterPro = []; (transformedData as TransformedInterPro | undefined)?.forEach( (feature) => { feature.locations?.forEach((location) => { @@ -221,7 +240,7 @@ class ProtvistaUniprot extends LitElement { this.data[`${categoryName}-${trackName}`] = filteredData; if (trackName === 'variation') { - this.transformedVariants = filteredData; + this.transformedVariants = filteredData as { sequence: string; variants: TransformedVariant[] }; } return filteredData; }) @@ -231,7 +250,7 @@ class ProtvistaUniprot extends LitElement { trackType === 'nightingale-linegraph-track' || trackType === 'nightingale-colored-sequence' ? categoryData[0] - : categoryData.flat(); + : (categoryData.flat() as Record[]); } } this.loading = false; @@ -246,20 +265,21 @@ class ProtvistaUniprot extends LitElement { ) as NightingaleTrackCanvas; // set data if it hasn't changed if (element && element.data !== data) { - element.data = data; + element.data = data as NightingaleTrackCanvas['data']; } const currentCategory = this.config?.categories.find( ({ name }) => name === id ); + const dataAsArray = data as { length?: number; variants?: TransformedVariant[] } | null; if ( currentCategory && currentCategory.tracks && - data && + dataAsArray && // Check there's data and special case for variants // NOTE: should refactor variation-adapter // to return a list of variants and set the sequence // on protvista-variation separately - (data.length > 0 || data.variants?.length) + ((dataAsArray.length ?? 0) > 0 || (dataAsArray.variants?.length ?? 0) > 0) ) { // Make category element visible const categoryElt = document.getElementById( @@ -273,7 +293,7 @@ class ProtvistaUniprot extends LitElement { `track-${id}-${track.name}` ) as NightingaleTrackCanvas | null; if (elementTrack) { - elementTrack.data = this.data[`${id}-${track.name}`]; + elementTrack.data = this.data[`${id}-${track.name}`] as NightingaleTrackCanvas['data']; } } } @@ -289,7 +309,7 @@ class ProtvistaUniprot extends LitElement { 'nightingale-sequence-heatmap' ); if (heatmapComponent && this.sequence) { - const heatmapData = this.data[`${id}-${track.name}`]; + const heatmapData = this.data[`${id}-${track.name}`] as { xValue: number; yValue: string; score: number }[]; const xDomain = Array.from( { length: this.sequence.length }, (_, i) => i + 1 @@ -297,9 +317,9 @@ class ProtvistaUniprot extends LitElement { const yDomain = [ ...new Set(heatmapData.map((hotMapItem) => hotMapItem.yValue)), ] as string[]; - heatmapComponent.setHeatmapData(xDomain, yDomain, heatmapData); + heatmapComponent.setHeatmapData(xDomain, yDomain, heatmapData as Parameters[2]); heatmapComponent.updateComplete.then(() => { - heatmapComponent.heatmapInstance.setColor((d) => + heatmapComponent.heatmapInstance?.setColor((d) => amColorScale(d.score) ); }); @@ -324,7 +344,7 @@ class ProtvistaUniprot extends LitElement { ); if (variationComponent && variationComponent?.colorConfig !== colorConfig) { - variationComponent.colorConfig = colorConfig; + variationComponent.colorConfig = colorConfig as (v: import('@nightingale-elements/nightingale-variation').VariationDatum) => string; } if (changedProperties.has('suspend')) { @@ -342,6 +362,7 @@ class ProtvistaUniprot extends LitElement { if (!this.accession) return; this.loadEntry(this.accession).then((entryData) => { + if (!entryData) return; this.sequence = entryData.sequence.sequence; this.displayCoordinates = { start: 1, end: this.sequence?.length }; // We need to get the length of the protein before rendering it @@ -380,13 +401,14 @@ class ProtvistaUniprot extends LitElement { }); } - async loadEntry(accession: string) { + async loadEntry(accession: string): Promise<{ sequence: { sequence: string } } | undefined> { try { return await ( await fetch(`https://www.ebi.ac.uk/proteins/api/proteins/${accession}`) - ).json(); + ).json() as { sequence: { sequence: string } }; } catch (e) { console.error(`Couldn't load UniProt entry`, e); + return undefined; } } @@ -528,7 +550,7 @@ class ProtvistaUniprot extends LitElement { } })} ${!category.tracks - ? this.data[category.name].map( + ? (this.data[category.name] as { accession?: string }[]).map( (item: { accession?: string }) => { if (this.openCategories.includes(category.name)) { if (!item || !item.accession) return ''; @@ -607,11 +629,11 @@ class ProtvistaUniprot extends LitElement { } } - groupByCategory(filters, category) { + groupByCategory(filters: Filter[] | undefined, category: string) { return filters?.filter((f) => f.type.name === category); } - getFilter(filters, filterName) { + getFilter(filters: Filter[] | undefined, filterName: string) { return filters?.filter((f) => f.name === filterName)?.[0]; } @@ -630,11 +652,11 @@ class ProtvistaUniprot extends LitElement { if (selectedFilters) { const selectedConsequenceFilters = selectedFilters - .map((f) => this.getFilter(consequenceFilters, f)) - .filter(Boolean); + .map((f: string) => this.getFilter(consequenceFilters, f)) + .filter(Boolean) as (Filter & { filterPredicate: (v: TransformedVariant) => unknown })[]; const selectedProvenanceFilters = selectedFilters - .map((f) => this.getFilter(provenanceFilters, f)) - .filter(Boolean); + .map((f: string) => this.getFilter(provenanceFilters, f)) + .filter(Boolean) as (Filter & { filterPredicate: (v: TransformedVariant) => unknown })[]; const filteredVariants = this.transformedVariants?.variants ?.filter((variant) => @@ -648,10 +670,11 @@ class ProtvistaUniprot extends LitElement { ) ); + const existing = this.data['VARIATION-variation']; this.data['VARIATION-variation'] = { - ...this.data['VARIATION-variation'], + ...(existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {}), variants: filteredVariants, - }; + } as TrackPayload; this._loadDataInComponents(); } diff --git a/src/tooltips/feature-tooltip.ts b/src/tooltips/feature-tooltip.ts index d5c3765..929d15b 100644 --- a/src/tooltips/feature-tooltip.ts +++ b/src/tooltips/feature-tooltip.ts @@ -45,7 +45,53 @@ const unimodIdMapping = { 'label: 13c(6)15n(4)': 267, }; -const formatSource = (source) => { +type EvidenceSource = { + name?: string; + id?: string; + url?: string; + alternativeUrl?: string; +}; + +type Evidence = { + code: string; + source?: EvidenceSource; +}; + +type Xref = { + name: string; + id: string; + url?: string; +}; + +type PTMDbReference = { + id: string; + properties: Record; +}; + +type PTMHighlight = { + name: string; + position: number; + dbReferences: PTMDbReference[]; +}; + +export type TooltipFeature = { + type?: string; + begin?: string | number; + end?: string | number; + description?: string; + ftId?: string; + alternativeSequence?: string; + peptide?: string; + unique?: boolean; + xrefs?: Xref[]; + evidences?: Evidence[]; + residuesToHighlight?: PTMHighlight[]; + ligandPart?: { name: string }; + ligand?: { name: string }; + [key: string]: unknown; +}; + +const formatSource = (source: EvidenceSource) => { if (source.name?.toLowerCase() === 'PubMed'.toLowerCase()) { return `${escapeHtml(source.id)} (${escapeHtml(source.name)} EuropePMC)`; } @@ -60,7 +106,7 @@ const formatSource = (source) => { return sourceLink; }; -export const getEvidenceFromCodes = (evidenceList) => { +export const getEvidenceFromCodes = (evidenceList: Evidence[] | undefined) => { if (!evidenceList) return ``; return `
    ${evidenceList @@ -75,7 +121,7 @@ export const getEvidenceFromCodes = (evidenceList) => { `; }; -export const formatXrefs = (xrefs) => { +export const formatXrefs = (xrefs: Xref[]) => { return `
      ${xrefs .map( (xref) => @@ -88,7 +134,7 @@ export const formatXrefs = (xrefs) => { .join('')}
    `; }; -const getPTMEvidence = (ptms, taxId) => { +const getPTMEvidence = (ptms: PTMHighlight[] | undefined, taxId: string | undefined) => { if (!ptms) return ``; const ids = ptms.flatMap(({ dbReferences }) => dbReferences.map((ref) => ref.id) @@ -106,8 +152,8 @@ const getPTMEvidence = (ptms, taxId) => { ? ` PRIDE)
  • Publication: 31819260 (PubMed)
  • ` : `` }${ - taxId && taxIdToPeptideAtlasBuildData[taxId] - ? ` PeptideAtlas` + taxId && taxIdToPeptideAtlasBuildData[taxId as keyof typeof taxIdToPeptideAtlasBuildData] + ? ` PeptideAtlas` : `` })`; }) @@ -115,7 +161,7 @@ const getPTMEvidence = (ptms, taxId) => { `; }; -const formatPTMPeptidoform = (peptide, ptms) => { +const formatPTMPeptidoform = (peptide: string, ptms: PTMHighlight[]) => { if (!ptms) return ``; const modificationValues = ptms.map((ptm) => ({ name: ptm.name, @@ -137,7 +183,7 @@ const formatPTMPeptidoform = (peptide, ptms) => { const formatProformaWithLink = (proforma = '') => { return proforma.replace(/\[([^\]]+)\]/g, (_, modification) => { - const id = unimodIdMapping[modification.toLowerCase()]; + const id = unimodIdMapping[modification.toLowerCase() as keyof typeof unimodIdMapping]; if (!id) { console.error('Unimod ID not found for modification:', modification); return `[${modification}]`; @@ -146,10 +192,10 @@ const formatProformaWithLink = (proforma = '') => { }); }; -const findModifiedResidueName = (feature, ptm) => { +const findModifiedResidueName = (feature: TooltipFeature, ptm: PTMHighlight) => { const { peptide, begin: peptideStart } = feature; const proteinLocation = Number(peptideStart) + ptm.position - 1; - const modifiedResidue = peptide.charAt(ptm.position - 1); // CharAt index starts from 0 + const modifiedResidue = (peptide ?? '').charAt(ptm.position - 1); // CharAt index starts from 0 switch (ptm.name) { case 'Phosphorylation': return `${proteinLocation} ${phosphorylate(modifiedResidue)}`; @@ -164,20 +210,20 @@ const findModifiedResidueName = (feature, ptm) => { } }; -const formatTooltip = (feature, taxId?: string) => { +const formatTooltip = (feature: TooltipFeature, taxId?: string) => { const evidenceHTML = feature.type === 'PROTEOMICS_PTM' ? getPTMEvidence(feature.residuesToHighlight, taxId) : getEvidenceFromCodes(feature.evidences); const ptms = feature.type === 'PROTEOMICS_PTM' && - feature.residuesToHighlight.map((ptm) => + feature.residuesToHighlight?.map((ptm) => findModifiedResidueName(feature, ptm) ); const dataset = feature.type === 'PROTEOMICS_PTM' && - feature.residuesToHighlight.flatMap(({ dbReferences }) => + feature.residuesToHighlight?.flatMap(({ dbReferences }) => dbReferences.map((ref) => ref.id) ); @@ -223,7 +269,7 @@ const formatTooltip = (feature, taxId?: string) => { feature.peptide && feature.type === 'PROTEOMICS_PTM' ? `
    Peptidoform

    ${escapeHtml(formatPTMPeptidoform( feature.peptide, - feature.residuesToHighlight + feature.residuesToHighlight ?? [] ))}

    ` : `` } @@ -247,7 +293,7 @@ const formatTooltip = (feature, taxId?: string) => { feature.residuesToHighlight && dataset && !dataset.includes('PXD012174') // Exclude 'Glue project' dataset as it is from PRIDE and it doesn't have PTM statistical attributes - ? `
    PTM statistical attributes
      ${feature.residuesToHighlight + ? `
      PTM statistical attributes
        ${(feature.residuesToHighlight ?? []) .map((ptm) => ptm.dbReferences .map( diff --git a/src/tooltips/ptm-tooltip.ts b/src/tooltips/ptm-tooltip.ts index 9a7aea3..7d9300b 100644 --- a/src/tooltips/ptm-tooltip.ts +++ b/src/tooltips/ptm-tooltip.ts @@ -126,7 +126,7 @@ const formatTooltip = ( return ` ${title ? `

        ${escapeHtml(title)}


        ` : ''} -
        Description

        ${escapeHtml(getDescription(modification, aa))}

        +
        Description

        ${escapeHtml(getDescription(modification ?? 'Phosphorylation', aa))}

        ${ confidenceScore ? `
        Confidence Score

        ${escapeHtml(confidenceScore)}

        ` diff --git a/src/tooltips/structure-tooltip.ts b/src/tooltips/structure-tooltip.ts index 81f78e2..4aba53b 100644 --- a/src/tooltips/structure-tooltip.ts +++ b/src/tooltips/structure-tooltip.ts @@ -1,6 +1,13 @@ import { escapeHtml, sanitizeUrl } from '../utils/security'; +import type { StructureFeature } from '../adapters/structure-adapter'; -const getStructuresHTML = (structureList) => { +type StructureItem = { + source: { url: string; id: string }; + start: number; + end: number; +}; + +const getStructuresHTML = (structureList: StructureItem[]) => { return `
          ${structureList .map( @@ -14,7 +21,7 @@ const getStructuresHTML = (structureList) => {
        `; }; -const formatTooltip = (feature) => { +const formatTooltip = (feature: StructureFeature) => { const structuresHTML = getStructuresHTML(feature.structures); return !structuresHTML ? '' diff --git a/src/tooltips/variation-tooltip.ts b/src/tooltips/variation-tooltip.ts index 0cdc024..ba891e5 100644 --- a/src/tooltips/variation-tooltip.ts +++ b/src/tooltips/variation-tooltip.ts @@ -54,7 +54,7 @@ const getEnsemblCovidLinks = (variant: Variant): string => { ); if (shouldGenerateLink) { const xref = variant.xrefs.find((xref) => xref.name === 'ENA'); - return xref.id + return xref?.id ? `
        Ensembl COVID-19

        ${escapeHtml(xref.id)} diff --git a/src/types/custom.d.ts b/src/types/custom.d.ts index 091d25e..cdb2b1a 100644 --- a/src/types/custom.d.ts +++ b/src/types/custom.d.ts @@ -1,4 +1,4 @@ declare module '*.svg' { - const content: any; + const content: string; export default content; } diff --git a/src/utils/index.ts b/src/utils/index.ts index ba08323..63ed8fa 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,4 @@ -export const renameProperties = (features) => +export const renameProperties = (features: { begin?: string | number; [key: string]: unknown }[]) => features.map((ft) => ({ ...ft, start: ft.begin || undefined, diff --git a/tsconfig.json b/tsconfig.json index 75c5948..460c31a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,8 +27,8 @@ /* Strict Type-Checking Options */ // "strict": true, /* Enable all strict type-checking options. */ - "noImplicitAny": false /* Pre-TS6 behavior; TS6 made this true by default. */, - "strictNullChecks": false /* Pre-TS6 behavior; TS6 made this true by default. */, + "noImplicitAny": true /* Raise error on implicit any types. */, + "strictNullChecks": true /* Enable strict null checks. */, // "strictFunctionTypes": true, /* Enable strict checking of function types. */ // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */