diff --git a/app/assets/javascripts/3dbio_viewer/src/data/repositories/BionotesPdbInfoRepository.ts b/app/assets/javascripts/3dbio_viewer/src/data/repositories/BionotesPdbInfoRepository.ts index b03fb40ce..d81970dac 100644 --- a/app/assets/javascripts/3dbio_viewer/src/data/repositories/BionotesPdbInfoRepository.ts +++ b/app/assets/javascripts/3dbio_viewer/src/data/repositories/BionotesPdbInfoRepository.ts @@ -3,34 +3,173 @@ import { FutureData } from "../../domain/entities/FutureData"; import { PdbId } from "../../domain/entities/Pdb"; import { buildPdbInfo, PdbInfo } from "../../domain/entities/PdbInfo"; import { ChainId, Protein, ProteinId } from "../../domain/entities/Protein"; -import { PdbInfoRepository } from "../../domain/repositories/PdbInfoRepository"; +import { GetPdbInfoArgs, PdbInfoRepository } from "../../domain/repositories/PdbInfoRepository"; import { routes } from "../../routes"; import { Future } from "../../utils/future"; import { RequestError, getFromUrl } from "../request-utils"; import { emdbsFromPdbUrl, getEmdbsFromMapping, PdbEmdbMapping } from "./mapping"; import { Maybe } from "../../utils/ts-utils"; +import { getStorageCache, setStorageCache } from "../storage-cache"; import i18n from "../../domain/utils/i18n"; export class BionotesPdbInfoRepository implements PdbInfoRepository { - get(pdbId: PdbId): FutureData { - const proteinMappingUrl = `${routes.bionotes}/api/mappings/PDB/Uniprot/${pdbId}`; - const fallbackProteinMappingUrl = `${routes.ebi}/pdbe/api/mappings/uniprot/${pdbId}`; - const polymerCoverage = `${routes.ebi}/pdbe/api/pdb/entry/polymer_coverage/${pdbId}/`; - const emdbMapping = `${emdbsFromPdbUrl}/${pdbId}`; + get(args: GetPdbInfoArgs): FutureData { + const { pdbId } = args; - const bionotesProteinMapping$ = getFromUrl>( - proteinMappingUrl - ).flatMapError( - (_err): FutureData> => Future.success(undefined) - ); + const data$ = { + uniprotMapping: this.getBionotesProteinMapping(pdbId), + fallbackProteinMapping: this.getEbiProteinMapping(pdbId), + molecules: this.getPolymerCoverage(pdbId), + emdbMapping: getFromUrl(`${emdbsFromPdbUrl}/${pdbId}`), + }; - const ebiProteinMapping$ = getFromUrl>( - fallbackProteinMappingUrl - ).flatMapError( - (_err): FutureData> => Future.success(undefined) + return Future.joinObj(data$).flatMap(data => this.getPdbInfo(data, args)); + } + + private getPdbInfo(data: Data, args: GetPdbInfoArgs): FutureData { + const { pdbId, onProcessDelay } = args; + const { uniprotMapping, fallbackProteinMapping, emdbMapping, molecules } = data; + + const hasProteinRes = uniprotMapping || fallbackProteinMapping; + if (!hasProteinRes) console.debug(`Uniprot mapping not found for ${pdbId}`); + + const chains = molecules + .flatMap(({ chains }) => chains) + .map(chain => ({ + structAsymId: chain.struct_asym_id, + chainId: chain.chain_id, + })); + + const proteinsMappingChains = this.getProteinChainsMappings({ + uniprotMapping, + pdbId, + chains, + fallbackProteinMapping, + }); + + console.debug("Chains with proteins: ", proteinsMappingChains); + + const proteinNames = this.getProteinNames({ + pdbId, + uniprotMapping, + fallbackProteinMapping, + }); + + const proteinsInfo$: FutureData = this.getProteinsInfo({ + proteinNames, + pdbId, + onProcessDelay, + }); + + const emdbs = getEmdbsFromMapping(emdbMapping, pdbId).map(id => ({ id })); + + return this.mapProteinsToPdbInfo({ proteinsInfo$, pdbId, emdbs, proteinsMappingChains }); + } + + private getProteinsInfo(args: { + proteinNames: string[]; + pdbId: string; + onProcessDelay: (reason: string) => void; + }): FutureData { + const { proteinNames, pdbId, onProcessDelay } = args; + + const proteinChunks = _.chunk(proteinNames, 4); + const isFirstFetch = !getStorageCache<{ proteinsInfo: boolean }>(pdbId)?.proteinsInfo; + + if (proteinChunks.length > 1 && isFirstFetch) + setTimeout(onProcessDelay("Fetching information for multiple UniProt IDs"), 2000); + + const proteinInfoRequests = proteinChunks.map(chunk => { + const proteinsChunk = chunk.join(","); + const proteinsInfoUrlChunk = `${routes.bionotes}/api/lengths/UniprotMulti/${proteinsChunk}`; + + return getFromUrl(proteinsInfoUrlChunk); + }); + + const proteinsInfo$: FutureData = this.collectProteinsInfo( + proteinInfoRequests, + pdbId ); - const polymerCoverage$ = getFromUrl(polymerCoverage) + return proteinsInfo$; + } + + private mapProteinsToPdbInfo(args: { + proteinsInfo$: FutureData; + pdbId: string; + emdbs: { id: string }[]; + proteinsMappingChains: ChainIdMapping[]; + }): FutureData { + const { proteinsInfo$, pdbId, emdbs, proteinsMappingChains } = args; + + return proteinsInfo$.map(proteinsInfo => { + const proteins = _(proteinsInfo) + .toPairs() + .map( + ([proteinId, proteinInfo]): Protein => { + const [_length, name, gen, organism] = proteinInfo; + return { id: proteinId, name, gen, organism }; + } + ) + .value(); + + return buildPdbInfo({ + id: pdbId, + emdbs: emdbs, + ligands: [], + proteins, + chainsMappings: proteinsMappingChains, + }); + }); + } + + private collectProteinsInfo( + proteinInfoRequests: FutureData[], + pdbId: string + ): FutureData { + return Future.parallel(proteinInfoRequests, { + maxConcurrency: 2, + }) + .map(responses => Object.assign({}, ...responses)) + .tap(() => { + setStorageCache(pdbId, { proteinsInfo: true }); + }); + } + + private getProteinNames(args: { + uniprotMapping: Maybe; + pdbId: string; + fallbackProteinMapping: Maybe; + }): string[] { + const { uniprotMapping, pdbId, fallbackProteinMapping } = args; + + const lowerPdbId = pdbId.toLowerCase(); + const uniprotData = uniprotMapping && uniprotMapping[lowerPdbId]; + const fallbackData = fallbackProteinMapping && fallbackProteinMapping[lowerPdbId]?.UniProt; + + const data = uniprotData ?? fallbackData; + + return _(data).keys().sort().value(); + } + + private getProteinChainsMappings(args: { + uniprotMapping: Maybe; + pdbId: string; + chains: ChainIdMapping[]; + fallbackProteinMapping: Maybe; + }): MappingChain[] { + const { uniprotMapping, pdbId, chains, fallbackProteinMapping } = args; + + if (uniprotMapping) return this.bionotesProteinMapping(pdbId, uniprotMapping, chains); + else if (fallbackProteinMapping) + return this.ebiProteinMapping(pdbId, fallbackProteinMapping, chains); + else return chains; + } + + private getPolymerCoverage(pdbId: string): FutureData { + const polymerCoverage = `${routes.ebi}/pdbe/api/pdb/entry/polymer_coverage/${pdbId}/`; + + return getFromUrl(polymerCoverage) .flatMap( (polymerCoverage): FutureData => { const molecules = polymerCoverage[pdbId.toLowerCase()]?.molecules; @@ -43,122 +182,81 @@ export class BionotesPdbInfoRepository implements PdbInfoRepository { } ) .flatMapError(err => buildError("noData", err)); + } - const emdbMapping$ = getFromUrl(emdbMapping); + private getEbiProteinMapping(pdbId: string): FutureData> { + const fallbackProteinMappingUrl = `${routes.ebi}/pdbe/api/mappings/uniprot/${pdbId}`; - const data$ = { - uniprotMapping: bionotesProteinMapping$, - fallbackProteinMapping: ebiProteinMapping$, - molecules: polymerCoverage$, - emdbMapping: emdbMapping$, - }; + return getFromUrl>(fallbackProteinMappingUrl).flatMapError( + (_err): FutureData> => Future.success(undefined) + ); + } - return Future.joinObj(data$).flatMap(data => { - const { uniprotMapping, fallbackProteinMapping, emdbMapping, molecules } = data; - - const hasProteinRes = uniprotMapping || fallbackProteinMapping; - - if (!hasProteinRes) console.debug(`Uniprot mapping not found for ${pdbId}`); - - const chains = molecules - .flatMap(({ chains }) => chains) - .map(chain => ({ - structAsymId: chain.struct_asym_id, - chainId: chain.chain_id, - })); - - const proteinsMappingChains = - (hasProteinRes && - ((uniprotMapping && - this.bionotesProteinMapping(pdbId, uniprotMapping, chains)) || - (fallbackProteinMapping && - this.ebiProteinMapping(pdbId, fallbackProteinMapping, chains)))) || - chains; - - const emdbs = getEmdbsFromMapping(emdbMapping, pdbId).map(emdbId => ({ id: emdbId })); - - const proteinsObj = - (uniprotMapping && uniprotMapping[pdbId.toLowerCase()]) ?? - (fallbackProteinMapping && fallbackProteinMapping[pdbId.toLowerCase()]?.UniProt); - - const proteins = proteinsObj && _(proteinsObj).keys().join(","); - const proteinsInfoUrl = `${routes.bionotes}/api/lengths/UniprotMulti/${proteins ?? ""}`; - const proteinsInfo$ = proteinsObj - ? getFromUrl(proteinsInfoUrl) - : Future.success({}); - - console.debug("Chains with proteins: ", proteinsMappingChains); - - return proteinsInfo$.map(proteinsInfo => { - const proteins = _(proteinsInfo) - .toPairs() - .map( - ([proteinId, proteinInfo]): Protein => { - const [_length, name, gen, organism] = proteinInfo; - return { id: proteinId, name, gen, organism }; - } - ) - .value(); - - return buildPdbInfo({ - id: pdbId, - emdbs: emdbs, - ligands: [], - proteins, - chainsMappings: proteinsMappingChains, - }); - }); - }); + private getBionotesProteinMapping(pdbId: string): FutureData> { + const proteinMappingUrl = `${routes.bionotes}/api/mappings/PDB/Uniprot/${pdbId}`; + + return getFromUrl>(proteinMappingUrl).flatMapError( + (_err): FutureData> => Future.success(undefined) + ); } private bionotesProteinMapping( pdbId: string, mapping: BioUniprotFromPdbMapping, - chains: ChainIds[] + chains: ChainIdMapping[] ): MappingChain[] { const proteins = mapping && mapping[pdbId.toLowerCase()]; if (!proteins || Array.isArray(proteins)) return chains; else { - const proteinsMappingChains = _.map(proteins, (proteinChains, protein) => - proteinChains.map(chainId => { - const structAsymId = chains.find(c => c.chainId === chainId)?.structAsymId; - if (!structAsymId) throw new Error("Missmatch between chains and proteins"); - - return { - structAsymId: structAsymId, - chainId: chainId, - protein: protein, - }; - }) - ).flat(); - - return _([...proteinsMappingChains, ...chains]) - .uniqBy("structAsymId") - .sortBy("structAsymId") - .value(); + const proteinsMappingChains = _.flatMap(proteins, getBioChainsByProtein(chains)); + + return mergeAndSortChains(proteinsMappingChains, chains); } } private ebiProteinMapping( pdbId: string, mapping: EbiUniprotFromPdbMapping, - chains: ChainIds[] + chains: ChainIdMapping[] ): MappingChain[] { const proteins = mapping && mapping[pdbId.toLowerCase()]?.UniProt; + const proteinsMappingChains = _.flatMap(proteins, getEbiChainsByProtein); + + return mergeAndSortChains(proteinsMappingChains, chains); + } +} + +function mergeAndSortChains( + proteinsMappingChains: MappingChain[], + chains: ChainIdMapping[] +): MappingChain[] { + return _(proteinsMappingChains) + .concat(chains) + .uniqBy(({ structAsymId }) => structAsymId) + .sortBy(({ structAsymId }) => structAsymId) + .value(); +} + +function getBioChainsByProtein(chains: ChainIdMapping[]) { + return (proteinChains: string[], protein: string): MappingChain[] => + proteinChains.map(chainId => { + const structAsymId = chains.find(c => c.chainId === chainId)?.structAsymId; + if (!structAsymId) throw new Error("Mismatch between chains and proteins"); - const proteinsMappingChains = _.map(proteins, (uniprotRes, protein) => - uniprotRes.mappings.map(({ struct_asym_id, chain_id }) => ({ - structAsymId: struct_asym_id, - chainId: chain_id, + return { + structAsymId: structAsymId, + chainId: chainId, protein: protein, - })) - ).flat(); + }; + }); +} - return _([...proteinsMappingChains, ...chains]) - .uniqBy("structAsymId") - .sortBy("structAsymId") - .value(); - } +function getEbiChainsByProtein(uniprotRes: EbiProteinMapping, protein: string): MappingChain[] { + return uniprotRes.mappings.map(({ struct_asym_id, chain_id }) => ({ + structAsymId: struct_asym_id, + chainId: chain_id, + protein: protein, + })); } type ErrorType = "serviceUnavailable" | "noData"; @@ -181,10 +279,14 @@ function buildError(type: ErrorType, err: RequestError): FutureData { } } -export type UniprotMapping = Record< - ProteinId, - { mappings: { chain_id: ChainId; struct_asym_id: ChainId }[] } ->; +type EbiProteinMapping = { + mappings: { + chain_id: ChainId; + struct_asym_id: ChainId; + }[]; +}; + +export type UniprotMapping = Record; type Uniprot = { UniProt: UniprotMapping; @@ -205,9 +307,16 @@ type ProteinsInfo = Record; // [length, name, uniprotCode, organism] type ProteinInfo = [number, string, string, string]; -type ChainIds = { +type ChainIdMapping = { structAsymId: string; chainId: string; }; -export type MappingChain = ChainIds & { protein?: string }; +export type MappingChain = ChainIdMapping & { protein?: string }; + +type Data = { + uniprotMapping: Maybe; + fallbackProteinMapping: Maybe; + molecules: PolymerMolecules; + emdbMapping: PdbEmdbMapping; +}; diff --git a/app/assets/javascripts/3dbio_viewer/src/data/request-utils.ts b/app/assets/javascripts/3dbio_viewer/src/data/request-utils.ts index b81be15d4..613718450 100644 --- a/app/assets/javascripts/3dbio_viewer/src/data/request-utils.ts +++ b/app/assets/javascripts/3dbio_viewer/src/data/request-utils.ts @@ -6,10 +6,11 @@ import { parseFromCodec } from "../utils/codec"; import { Future } from "../utils/future"; import { axiosRequest, defaultBuilder, RequestResult } from "../utils/future-axios"; import { Maybe } from "../utils/ts-utils"; +import { getStorageCache, hashUrl, setStorageCache } from "./storage-cache"; export type RequestError = { message: string }; -const timeout = 20e3; +const timeout = 30e3; export function getFromUrl(url: string): Future { return request({ method: "GET", url, timeout }).map(res => res.data); @@ -80,5 +81,22 @@ function xmlToJs(xml: string): Future { export function request( request: AxiosRequestConfig ): Future> { - return axiosRequest(defaultBuilder, request); + if (!request.url) { + return axiosRequest(defaultBuilder, request); + } + + const params = request.params + ? JSON.stringify(request.params, Object.keys(request.params).sort()) + : ""; + + const cacheKey = hashUrl(request.url + params); + const cachedResult = getStorageCache>(cacheKey); + if (cachedResult) return Future.success(cachedResult); + + return axiosRequest(defaultBuilder, request).map(result => { + if (result.response.status >= 200 && result.response.status < 300) { + setStorageCache(cacheKey, result); + } + return result; + }); } diff --git a/app/assets/javascripts/3dbio_viewer/src/data/storage-cache.ts b/app/assets/javascripts/3dbio_viewer/src/data/storage-cache.ts new file mode 100644 index 000000000..95162f97e --- /dev/null +++ b/app/assets/javascripts/3dbio_viewer/src/data/storage-cache.ts @@ -0,0 +1,61 @@ +import { createHash } from "crypto"; +import { Maybe } from "../utils/ts-utils"; + +const cacheExpiresMs = 60 * 60 * 1000; // 1 hour + +export function hashUrl(url: string): string { + return createHash("sha256").update(url).digest("hex"); +} + +export function getStorageCache(key: string): Maybe { + const cached = localStorage.getItem(key); + if (!cached) return undefined; + + try { + const { value, timestamp } = JSON.parse(cached) as { + value: Data; + timestamp: number; + }; + + if (Date.now() - timestamp > cacheExpiresMs) { + localStorage.removeItem(key); + return undefined; + } + + return value; + } catch (error) { + console.error("Error parsing storage cache:", error); + localStorage.removeItem(key); + + return undefined; + } +} + +export function setStorageCache(key: string, value: Data): void { + const cacheEntry = { + value, + timestamp: Date.now(), + }; + + localStorage.setItem(key, JSON.stringify(cacheEntry)); +} + +// To be called on index, when page loads +export function storageGarbageCollector(): void { + const keysToRemove = Array.from({ length: localStorage.length }) + .map((_, i) => localStorage.key(i)) + .filter((key): key is string => key !== null) + .filter(key => { + const item = localStorage.getItem(key); + if (!item) return false; + try { + const { timestamp } = JSON.parse(item) as { timestamp: number }; + return Date.now() - timestamp > cacheExpiresMs; + } catch (error) { + console.error("Error parsing storage cache during garbage collection:", error); + return true; + } + }); + + keysToRemove.forEach(key => localStorage.removeItem(key)); +} diff --git a/app/assets/javascripts/3dbio_viewer/src/domain/entities/Fragment.ts b/app/assets/javascripts/3dbio_viewer/src/domain/entities/Fragment.ts index 5b46ea295..818e514de 100644 --- a/app/assets/javascripts/3dbio_viewer/src/domain/entities/Fragment.ts +++ b/app/assets/javascripts/3dbio_viewer/src/domain/entities/Fragment.ts @@ -52,7 +52,7 @@ export function getFragmentToolsLink(options: { } export function isBlastFragment(subtrack: Subtrack, fragment: FragmentU): boolean { - return subtrack.isBlast !== false && fragment.end - fragment.start >= 3; + return Boolean(subtrack.isBlast) && fragment.end - fragment.start >= 3; } export function getBlastUrl(protein: string, subtrack: Subtrack, fragment: FragmentU): string { diff --git a/app/assets/javascripts/3dbio_viewer/src/domain/entities/Fragment2.ts b/app/assets/javascripts/3dbio_viewer/src/domain/entities/Fragment2.ts index 1ba20801c..ee0aafd69 100644 --- a/app/assets/javascripts/3dbio_viewer/src/domain/entities/Fragment2.ts +++ b/app/assets/javascripts/3dbio_viewer/src/domain/entities/Fragment2.ts @@ -82,7 +82,7 @@ export function getTracksFromFragments(fragments: Fragments): Track[] { labelTooltip: subtrack.description, shape: subtrack.shape || "rectangle", source: subtrack.source, - isBlast: subtrack.isBlast ?? true, + isBlast: subtrack.isBlast, locations: slots.map(fragments => ({ fragments })), subtype: subtrack.subtype, overlapping: false, @@ -158,6 +158,24 @@ export interface Interval { end: number; } +type FragmentP = Fragment | Fragment2; + +export function isCovered(alignment: Interval[], fragment: FragmentP): boolean { + return alignment.some( + interval => fragment.start >= interval.start && fragment.end <= interval.end + ); +} + +export function isPartiallyCovered(alignment: Interval[], fragment: FragmentP): boolean { + return alignment.some( + interval => fragment.start <= interval.end && fragment.end >= interval.start + ); +} + +export function isNotCovered(alignment: Interval[], fragment: FragmentP): boolean { + return !isCovered(alignment, fragment) && !isPartiallyCovered(alignment, fragment); +} + export function getIntervalKey(obj: T): string { return [obj.start, obj.end].join("-"); } diff --git a/app/assets/javascripts/3dbio_viewer/src/domain/entities/PdbInfo.ts b/app/assets/javascripts/3dbio_viewer/src/domain/entities/PdbInfo.ts index a775e8a76..877dd5674 100644 --- a/app/assets/javascripts/3dbio_viewer/src/domain/entities/PdbInfo.ts +++ b/app/assets/javascripts/3dbio_viewer/src/domain/entities/PdbInfo.ts @@ -77,8 +77,8 @@ export function getPdbInfoFromUploadData(uploadData: UploadData): PdbInfo { id: chain.chain, name: chain.name, shortName: chain.name, - chainId: chain.chain, //THIS MUST BE AKNOLWEDGED - structAsymId: chain.chain, //THIS MUST BE AKNOLWEDGED + chainId: chain.chain, // TODO: THIS MUST BE ACKNOWLEDGED + structAsymId: chain.chain, // TODO: THIS MUST BE ACKNOWLEDGED protein: { id: chain.uniprot, name: chain.uniprotTitle, diff --git a/app/assets/javascripts/3dbio_viewer/src/domain/entities/Protein.ts b/app/assets/javascripts/3dbio_viewer/src/domain/entities/Protein.ts index 6b9b52796..e9a078659 100644 --- a/app/assets/javascripts/3dbio_viewer/src/domain/entities/Protein.ts +++ b/app/assets/javascripts/3dbio_viewer/src/domain/entities/Protein.ts @@ -38,6 +38,7 @@ export interface NMRFragment { export type ProteinId = string; export type ChainId = string; +export type StructAsymId = string; type ProteinEntity = "uniprot" | "geneBank"; diff --git a/app/assets/javascripts/3dbio_viewer/src/domain/repositories/PdbInfoRepository.ts b/app/assets/javascripts/3dbio_viewer/src/domain/repositories/PdbInfoRepository.ts index 815d43457..a97f290d9 100644 --- a/app/assets/javascripts/3dbio_viewer/src/domain/repositories/PdbInfoRepository.ts +++ b/app/assets/javascripts/3dbio_viewer/src/domain/repositories/PdbInfoRepository.ts @@ -3,5 +3,10 @@ import { PdbId } from "../entities/Pdb"; import { PdbInfo } from "../entities/PdbInfo"; export interface PdbInfoRepository { - get(pdbId: PdbId): FutureData; + get(args: GetPdbInfoArgs): FutureData; } + +export type GetPdbInfoArgs = { + pdbId: PdbId; + onProcessDelay: (reason: string) => void; +}; diff --git a/app/assets/javascripts/3dbio_viewer/src/domain/repositories/PdbRepository.ts b/app/assets/javascripts/3dbio_viewer/src/domain/repositories/PdbRepository.ts index 0cda4196e..2eee04b2b 100644 --- a/app/assets/javascripts/3dbio_viewer/src/domain/repositories/PdbRepository.ts +++ b/app/assets/javascripts/3dbio_viewer/src/domain/repositories/PdbRepository.ts @@ -1,7 +1,7 @@ import { Maybe } from "../../utils/ts-utils"; import { FutureData } from "../entities/FutureData"; import { Pdb, PdbId } from "../entities/Pdb"; -import { ProteinId, ChainId } from "../entities/Protein"; +import { ProteinId, ChainId, StructAsymId } from "../entities/Protein"; import { IDROptions } from "../usecases/GetPdbUseCase"; export interface PdbRepository { @@ -12,5 +12,5 @@ export interface PdbOptions { proteinId: Maybe; pdbId: PdbId; chainId: ChainId; - structAsymId: string; + structAsymId: StructAsymId; } diff --git a/app/assets/javascripts/3dbio_viewer/src/domain/usecases/ExportAllAnnotationsUseCase.ts b/app/assets/javascripts/3dbio_viewer/src/domain/usecases/ExportAllAnnotationsUseCase.ts index 828a623a4..039c5a7b7 100644 --- a/app/assets/javascripts/3dbio_viewer/src/domain/usecases/ExportAllAnnotationsUseCase.ts +++ b/app/assets/javascripts/3dbio_viewer/src/domain/usecases/ExportAllAnnotationsUseCase.ts @@ -5,7 +5,7 @@ import { AnnotationsExportRepository } from "../repositories/AnnotationsExportRe import i18n from "../utils/i18n"; export class ExportAllAnnotationsUseCase { - constructor(private annotationsExportRepository: AnnotationsExportRepository) { } + constructor(private annotationsExportRepository: AnnotationsExportRepository) {} execute(options: { proteinId: Maybe; @@ -14,9 +14,7 @@ export class ExportAllAnnotationsUseCase { emdbs: Emdb[]; }): Future { const { pdbId } = options; - if (!pdbId) return Future.error( - i18n.t("Unable to download annotations without PDB ID.") - ); + if (!pdbId) return Future.error(i18n.t("Unable to download annotations without PDB ID.")); return this.annotationsExportRepository .exportAllAnnotations(options) diff --git a/app/assets/javascripts/3dbio_viewer/src/domain/usecases/GetPdbInfoUseCase.ts b/app/assets/javascripts/3dbio_viewer/src/domain/usecases/GetPdbInfoUseCase.ts index 4dbf9283f..40f68a9ac 100644 --- a/app/assets/javascripts/3dbio_viewer/src/domain/usecases/GetPdbInfoUseCase.ts +++ b/app/assets/javascripts/3dbio_viewer/src/domain/usecases/GetPdbInfoUseCase.ts @@ -1,12 +1,11 @@ import { FutureData } from "../entities/FutureData"; -import { PdbId } from "../entities/Pdb"; import { PdbInfo } from "../entities/PdbInfo"; -import { PdbInfoRepository } from "../repositories/PdbInfoRepository"; +import { GetPdbInfoArgs, PdbInfoRepository } from "../repositories/PdbInfoRepository"; export class GetPdbInfoUseCase { constructor(private pdbInfoRepository: PdbInfoRepository) {} - execute(pdbId: PdbId): FutureData { - return this.pdbInfoRepository.get(pdbId); + execute(args: GetPdbInfoArgs): FutureData { + return this.pdbInfoRepository.get(args); } } diff --git a/app/assets/javascripts/3dbio_viewer/src/index.tsx b/app/assets/javascripts/3dbio_viewer/src/index.tsx index ba0008c66..72ac5a653 100644 --- a/app/assets/javascripts/3dbio_viewer/src/index.tsx +++ b/app/assets/javascripts/3dbio_viewer/src/index.tsx @@ -3,7 +3,9 @@ import ReactDOM from "react-dom"; import reportWebVitals from "./reportWebVitals"; import App from "./webapp/pages/app/App"; import "./index.css"; +import { storageGarbageCollector } from "./data/storage-cache"; ReactDOM.render(, document.getElementById("root")); reportWebVitals(); +storageGarbageCollector(); diff --git a/app/assets/javascripts/3dbio_viewer/src/webapp/components/RootViewerContents.tsx b/app/assets/javascripts/3dbio_viewer/src/webapp/components/RootViewerContents.tsx index 82879447c..491bca9c7 100644 --- a/app/assets/javascripts/3dbio_viewer/src/webapp/components/RootViewerContents.tsx +++ b/app/assets/javascripts/3dbio_viewer/src/webapp/components/RootViewerContents.tsx @@ -35,9 +35,9 @@ export type LoaderKey = keyof typeof loaderMessages; const loaderMessages = { readingSequence: [i18n.t("Reading sequence..."), 0], getRelatedPdbModel: [i18n.t("Getting PDB related model..."), 0], - initPlugin: [i18n.t("Starting 3D Viewer..."), 1], //already loading PDB + pdbLoader: [i18n.t("Loading PDB Data..."), 1], updateVisualPlugin: [i18n.t("Updating selection..."), 2], - pdbLoader: [i18n.t("Loading PDB Data..."), 3], + initPlugin: [i18n.t("Starting 3D Viewer..."), 3], //already loading PDB uploadedModel: [i18n.t("Loading uploaded model..."), 2], loadModel: [i18n.t("Loading model..."), 4], //PDB, EMDB, PDB-REDO, CSTF, CERES exportAnnotations: [i18n.t("Retrieving all annotations..."), 5], @@ -61,8 +61,9 @@ export const RootViewerContents: React.FC = React.memo( const { loading, title, + setLoader, updateLoaderStatus, - updateOnResolve: updateLoader, + updateOnResolve, loaders, resetLoaders, } = useMultipleLoaders(loadersInitialState); @@ -76,7 +77,20 @@ export const RootViewerContents: React.FC = React.memo( const uploadData = getUploadData(externalData); - const { pdbInfoLoader, setLigands } = usePdbInfo(selection, uploadData); + const onProcessDelay = React.useCallback( + (reason: string) => + setLoader("pdbLoader", { + status: "loading", + message: i18n.t( + "Loading PDB Data...\n{{reason}}\nThis can take several minutes to load.", + { reason: reason } + ), + priority: 10, + }), + [setLoader] + ); + + const { pdbInfoLoader, setLigands } = usePdbInfo({ selection, uploadData, onProcessDelay }); const [pdbLoader, setPdbLoader] = usePdbLoader(selection, pdbInfoLoader); const pdbInfo = pdbInfoLoader.type === "loaded" ? pdbInfoLoader.data : undefined; @@ -114,12 +128,27 @@ export const RootViewerContents: React.FC = React.memo( }, [uploadDataToken, networkToken, compositionRoot]); const pdbId = React.useMemo(() => getMainItem(selection, "pdb"), [selection]); + const prevPdbId = React.useRef(pdbId); + + const chainId = selection.chainId; + const prevChainId = React.useRef(chainId); + + React.useEffect(() => { + if (pdbId && pdbId !== prevPdbId.current) resetLoaders(loadersInitialState); + }, [pdbId, prevPdbId, resetLoaders]); + + React.useEffect(() => { + if (chainId && chainId !== prevChainId.current) + setLoader("pdbLoader", loadersInitialState.pdbLoader); + }, [chainId, pdbId, prevPdbId, resetLoaders, setLoader]); + + React.useEffect(() => { + prevPdbId.current = pdbId; + }, [pdbId]); React.useEffect(() => { - const init = loaders.initPlugin; - if (init.status !== "loaded") return; - resetLoaders({ ...loadersInitialState, initPlugin: init }); - }, [pdbId, resetLoaders, loaders.initPlugin]); + prevChainId.current = chainId; + }, [chainId]); React.useEffect(() => { const critical = criticalLoaders.find(loader => loader.status === "error"); @@ -150,7 +179,7 @@ export const RootViewerContents: React.FC = React.memo( onSelectionChange={setSelection} onLigandsLoaded={setLigands} proteinNetwork={proteinNetwork} - updateLoader={updateLoader} + updateLoader={updateOnResolve} loaderBusy={loading} /> @@ -171,7 +200,7 @@ export const RootViewerContents: React.FC = React.memo( pdbLoader={pdbLoader} setPdbLoader={setPdbLoader} toolbarExpanded={toolbarExpanded} - updateLoader={updateLoader} + updateLoader={updateOnResolve} /> diff --git a/app/assets/javascripts/3dbio_viewer/src/webapp/components/dropdown/Dropdown.tsx b/app/assets/javascripts/3dbio_viewer/src/webapp/components/dropdown/Dropdown.tsx index 39aa1b686..2e1c9cf94 100644 --- a/app/assets/javascripts/3dbio_viewer/src/webapp/components/dropdown/Dropdown.tsx +++ b/app/assets/javascripts/3dbio_viewer/src/webapp/components/dropdown/Dropdown.tsx @@ -41,7 +41,7 @@ export function Dropdown( leftIcon, expanded, deselectable, - disabled + disabled = false, } = props; const [isMenuOpen, { enable: openMenu, disable: closeMenu }] = useBooleanState(false); const buttonRef = React.useRef(null); @@ -89,17 +89,18 @@ export function Dropdown( - {items && items.map(item => ( - - key={item.id} - onClick={runOnClickAndCloseMenu} - item={item} - isSelected={item.id === selected} - showSelection={showSelection} - > - {item.text} - - ))} + {items && + items.map(item => ( + + key={item.id} + onClick={runOnClickAndCloseMenu} + item={item} + isSelected={item.id === selected} + showSelection={showSelection} + > + {item.text} + + ))} ); diff --git a/app/assets/javascripts/3dbio_viewer/src/webapp/components/frame-viewer/FrameViewer.tsx b/app/assets/javascripts/3dbio_viewer/src/webapp/components/frame-viewer/FrameViewer.tsx index 566b4386b..747a7a8ab 100644 --- a/app/assets/javascripts/3dbio_viewer/src/webapp/components/frame-viewer/FrameViewer.tsx +++ b/app/assets/javascripts/3dbio_viewer/src/webapp/components/frame-viewer/FrameViewer.tsx @@ -155,7 +155,7 @@ const StyledAccordionSummary = styled(AccordionSummary)` &.MuiAccordionSummary-root { border-top: 0.5px solid #fff; - background: #f7f7f7; + background: #f0f0f0; min-height: 45px; display: flex; align-items: flex-start; @@ -200,24 +200,29 @@ const StyledAccordionSummary = styled(AccordionSummary)` } .viewer-subtrack { - width: 60%; + width: 100%%; + display: flex; + align-items: center; + justify-content: space-between; background-color: #f7f7f7; - padding: 0.5em 1.2em 0.5em 1.8em; + padding: 0.5em 1em; border-bottom: 1px solid #e2e2e2; line-height: 22px; + min-height: 45px; cursor: pointer; position: relative; - /*word-break: break-all;*/ + box-sizing: border-box; .subtrack-help { - position: absolute; - top: 50%; - transform: translateY(-50%); - right: 0.5em; - background-color: rgba(0, 0, 0, 0); - color: #848a86; - border: solid 1px #848a86; - cursor: pointer; + all: unset; + background-color: #ffffff; + border: 1px solid #d6d6d6; + border-radius: 0.5em; + color: #6d6d6d; + padding: 0.125em 0.5em; + margin-left: 1em; + min-width: 14px; + text-align: center; } } diff --git a/app/assets/javascripts/3dbio_viewer/src/webapp/components/loader-mask/LoaderMask.tsx b/app/assets/javascripts/3dbio_viewer/src/webapp/components/loader-mask/LoaderMask.tsx index 370d2266f..32ab5d24f 100644 --- a/app/assets/javascripts/3dbio_viewer/src/webapp/components/loader-mask/LoaderMask.tsx +++ b/app/assets/javascripts/3dbio_viewer/src/webapp/components/loader-mask/LoaderMask.tsx @@ -4,18 +4,24 @@ import styled from "styled-components"; interface LoaderProps { open: boolean; - title?: string; + title: string; } export const LoaderMask: React.FC = React.memo(props => { const classes = useStyles(); const { open, title } = props; + const pClassName = (idx: number) => (idx === 0 ? classes.title : classes.subsequentParagraphs); + return ( - {title &&

{title}

} + {title.split("\n").map((line, idx) => ( +

+ {line} +

+ ))}
); @@ -31,6 +37,12 @@ const useStyles = makeStyles(theme => ({ fontWeight: "bold", fontSize: "1em", marginTop: "1em", + marginBottom: "0.5em", + }, + subsequentParagraphs: { + fontWeight: "bold", + fontSize: "1em", + margin: "0 0 0.5em", }, })); diff --git a/app/assets/javascripts/3dbio_viewer/src/webapp/components/molecular-structure/MolstarState.ts b/app/assets/javascripts/3dbio_viewer/src/webapp/components/molecular-structure/MolstarState.ts index 4faf019d8..edd69b56a 100644 --- a/app/assets/javascripts/3dbio_viewer/src/webapp/components/molecular-structure/MolstarState.ts +++ b/app/assets/javascripts/3dbio_viewer/src/webapp/components/molecular-structure/MolstarState.ts @@ -23,10 +23,10 @@ export class MolstarStateActions { return initParams.ligandView ? { type: "ligand" } : { - type: "pdb", - chainId: newSelection.chainId, - items: _.compact([pdbItem, emdbItem]), - }; + type: "pdb", + chainId: newSelection.chainId, + items: _.compact([pdbItem, emdbItem]), + }; } static updateItems(state: MolstarState, items: DbItem[]): MolstarState { diff --git a/app/assets/javascripts/3dbio_viewer/src/webapp/components/protvista/ProtvistaPdbValidation.tsx b/app/assets/javascripts/3dbio_viewer/src/webapp/components/protvista/ProtvistaPdbValidation.tsx index 27d078797..1a0e094fe 100644 --- a/app/assets/javascripts/3dbio_viewer/src/webapp/components/protvista/ProtvistaPdbValidation.tsx +++ b/app/assets/javascripts/3dbio_viewer/src/webapp/components/protvista/ProtvistaPdbValidation.tsx @@ -13,7 +13,7 @@ declare global { export const ProtvistaPdbValidation: React.FC = React.memo(props => { const stats = _.first(props.pdb.emdbs)?.emv?.stats; - const _ref = useGrid(); /*temporal hidden*/ + /* const _ref = useGrid(); /* temporal hidden */ return ( <> @@ -141,7 +141,7 @@ const SVGBar: React.FC = React.memo(({ stats }) => { ); }); -function useGrid() { +function _useGrid() { const svgRef = React.useRef(null); React.useEffect(() => { diff --git a/app/assets/javascripts/3dbio_viewer/src/webapp/components/protvista/ProvistaGrouped.tsx b/app/assets/javascripts/3dbio_viewer/src/webapp/components/protvista/ProvistaGrouped.tsx index 9ef5a7eba..48173ffed 100644 --- a/app/assets/javascripts/3dbio_viewer/src/webapp/components/protvista/ProvistaGrouped.tsx +++ b/app/assets/javascripts/3dbio_viewer/src/webapp/components/protvista/ProvistaGrouped.tsx @@ -13,7 +13,11 @@ export interface ProtvistaGroupedProps {} export const ProtvistaGrouped: React.FC = React.memo(() => { const viewerState = useViewerState({ type: "selector" }); const { selection } = viewerState; - const { pdbInfoLoader } = usePdbInfo(selection, undefined); + const { pdbInfoLoader } = usePdbInfo({ + selection, + uploadData: undefined, + onProcessDelay: () => {}, + }); const [loader, _setLoader] = usePdbLoader(selection, pdbInfoLoader); const block = testblock; diff --git a/app/assets/javascripts/3dbio_viewer/src/webapp/components/protvista/Tooltip.tsx b/app/assets/javascripts/3dbio_viewer/src/webapp/components/protvista/Tooltip.tsx index e7ddedf5d..9c6ccff77 100644 --- a/app/assets/javascripts/3dbio_viewer/src/webapp/components/protvista/Tooltip.tsx +++ b/app/assets/javascripts/3dbio_viewer/src/webapp/components/protvista/Tooltip.tsx @@ -1,8 +1,17 @@ -import React from "react"; import _ from "lodash"; +import React from "react"; +import styled from "styled-components"; +import { InfoOutlined as InfoOutlinedIcon } from "@material-ui/icons"; import { Reference } from "../../../domain/entities/Evidence"; import { Fragment, getFragmentToolsLink } from "../../../domain/entities/Fragment"; -import { Fragment2, getConflict } from "../../../domain/entities/Fragment2"; +import { + Fragment2, + Interval, + getConflict, + isCovered, + isNotCovered, + isPartiallyCovered, +} from "../../../domain/entities/Fragment2"; import { Pdb } from "../../../domain/entities/Pdb"; import { Subtrack } from "../../../domain/entities/Track"; import { renderJoin } from "../../utils/react"; @@ -10,12 +19,12 @@ import { Link } from "../Link"; import { Protein } from "../../../domain/entities/Protein"; import { getSource, Source as SourceEntity } from "../../../domain/entities/Source"; import i18n from "../../utils/i18n"; -import styled from "styled-components"; interface TooltipProps { pdb: Pdb; subtrack: Subtrack; fragment: FragmentP; + alignment: Interval[]; sources: SourceEntity[]; } @@ -23,21 +32,46 @@ type FragmentP = Fragment | Fragment2; export const Tooltip: React.FC = React.memo(props => { //cannot use sources from AppContext as context is not initalized - const { pdb, subtrack, fragment, sources } = props; + const { pdb, subtrack, fragment, alignment, sources } = props; const { description, alignmentScore } = fragment; //no react.memo as is finally rendered to string const nmrSource = getSource(sources, "NMR"); - const score = alignmentScore ? alignmentScore + " %" : undefined; + const score = alignmentScore ? alignmentScore + " %" : undefined; // aligmentScore is never being set on code const isNMR = subtrack.accession === "nmr"; + const covered = isCovered(alignment, fragment); + const partiallyCovered = isPartiallyCovered(alignment, fragment); + const notCovered = isNotCovered(alignment, fragment); + + const coverage = [ + { condition: notCovered, details: getCoverageDetails(i18n).notCovered }, + { + condition: partiallyCovered && !covered, + details: getCoverageDetails(i18n).partiallyCovered, + }, + ].find(item => item.condition)?.details; + return ( + {coverage && ( + + {info => } + + )} + + + @@ -45,6 +79,7 @@ export const Tooltip: React.FC = React.memo(props => { {pdb.protein && } + {isNMR && nmrSource && ( )} @@ -52,17 +87,6 @@ export const Tooltip: React.FC = React.memo(props => { ); }); -const styles = { - tooltip: { - borderColor: "black", - display: "inline-flex", - width: 10, - borderWidth: 1, - height: 10, - marginRight: 5, - }, -}; - const NMR: React.FC<{ start: number; end: number; nmrSource: SourceEntity }> = props => { const { nmrSource } = props; const nmrMethod = nmrSource?.methods[0]; @@ -224,3 +248,33 @@ const ButtonLink = styled.button` padding: 0; font-weight: normal; `; + +type I18N = typeof i18n; + +const getCoverageDetails = (i18n: I18N) => ({ + notCovered: { + value: i18n.t("Not in Structure Coverage"), + className: "error", + title: i18n.t( + "This annotation is part of the full UniProt sequence but lies outside the region captured in the 3D structure (PDB) for this specific chain. It may correspond to a flexible, disordered, or missing part of the protein that was not resolved during structure determination." + ), + }, + partiallyCovered: { + value: i18n.t("Partially in Structure Coverage"), + className: "warning", + title: i18n.t( + "This annotation is partially covered by the 3D structure (PDB) for this specific chain. The structure may capture part of this region, but additional functional or structural details extend beyond the resolved area." + ), + }, +}); + +const styles = { + tooltip: { + borderColor: "black", + display: "inline-flex", + width: 10, + borderWidth: 1, + height: 10, + marginRight: 5, + }, +}; diff --git a/app/assets/javascripts/3dbio_viewer/src/webapp/components/protvista/protvista-pdb.css b/app/assets/javascripts/3dbio_viewer/src/webapp/components/protvista/protvista-pdb.css index 860ebef01..02fe83449 100644 --- a/app/assets/javascripts/3dbio_viewer/src/webapp/components/protvista/protvista-pdb.css +++ b/app/assets/javascripts/3dbio_viewer/src/webapp/components/protvista/protvista-pdb.css @@ -105,6 +105,14 @@ protvista-tooltip .description { color: rgb(192, 192, 192); } +protvista-tooltip tr.error td { + color: #8b0000; +} + +protvista-tooltip tr.warning td { + color: #f57f17; +} + .protvistaToolbar { text-align: center; } @@ -125,3 +133,9 @@ protvista-tooltip .tooltip-close:hover { protvista-tooltip { max-width: 500px; } + +protvista-tooltip svg.MuiSvgIcon-fontSizeSmall { + font-size: 0.9rem; + vertical-align: text-top; + margin-left: 0.25rem; +} diff --git a/app/assets/javascripts/3dbio_viewer/src/webapp/components/viewer-selector/ViewerSelector.tsx b/app/assets/javascripts/3dbio_viewer/src/webapp/components/viewer-selector/ViewerSelector.tsx index 46a9b9b06..98364a82b 100644 --- a/app/assets/javascripts/3dbio_viewer/src/webapp/components/viewer-selector/ViewerSelector.tsx +++ b/app/assets/javascripts/3dbio_viewer/src/webapp/components/viewer-selector/ViewerSelector.tsx @@ -219,7 +219,7 @@ function useLigandsDropdown(options: ViewerSelectorProps): DropdownProps { selected: selectedLigand?.shortId, deselectable: true, expanded, - disabled: _.isEmpty(items) + disabled: _.isEmpty(items), }; } diff --git a/app/assets/javascripts/3dbio_viewer/src/webapp/hooks/loader-hooks.ts b/app/assets/javascripts/3dbio_viewer/src/webapp/hooks/loader-hooks.ts index acd993691..d6ef7ad87 100644 --- a/app/assets/javascripts/3dbio_viewer/src/webapp/hooks/loader-hooks.ts +++ b/app/assets/javascripts/3dbio_viewer/src/webapp/hooks/loader-hooks.ts @@ -37,18 +37,23 @@ export function useStateFromFuture( return loader; } -export function usePdbInfo(selection: Selection, uploadData: Maybe) { +export function usePdbInfo(args: { + selection: Selection; + uploadData: Maybe; + onProcessDelay: (reason: string) => void; +}) { + const { selection, uploadData, onProcessDelay } = args; const { compositionRoot } = useAppContext(); const mainPdbId = getMainItem(selection, "pdb"); const [ligands, setLigands] = React.useState(); const getPdbInfo = React.useCallback((): Maybe> => { if (mainPdbId) { - return compositionRoot.getPdbInfo.execute(mainPdbId); + return compositionRoot.getPdbInfo.execute({ pdbId: mainPdbId, onProcessDelay }); } else if (uploadData) { return Future.success(getPdbInfoFromUploadData(uploadData)); } - }, [mainPdbId, compositionRoot, uploadData]); + }, [mainPdbId, compositionRoot, uploadData, onProcessDelay]); const pdbInfoLoader = useStateFromFuture(getPdbInfo); @@ -73,10 +78,7 @@ export function useMultipleLoaders(initialState: MultipleLoade [] ); - const resetLoaders = React.useCallback( - (state: MultipleLoader) => setLoaders(state), - [] - ); + const resetLoaders = React.useCallback((state: MultipleLoader) => setLoaders(state), []); const updateLoaderStatus = React.useCallback( (key: K, status: Loader["status"], newMessage?: string) => @@ -89,13 +91,13 @@ export function useMultipleLoaders(initialState: MultipleLoade return message ? { - ...loaders, - [key]: { - message, - status, - priority, - }, - } + ...loaders, + [key]: { + message, + status, + priority, + }, + } : loaders; }), [] diff --git a/app/assets/javascripts/3dbio_viewer/src/webapp/view-models/PdbView.ts b/app/assets/javascripts/3dbio_viewer/src/webapp/view-models/PdbView.ts index 622c775e1..3889e0530 100644 --- a/app/assets/javascripts/3dbio_viewer/src/webapp/view-models/PdbView.ts +++ b/app/assets/javascripts/3dbio_viewer/src/webapp/view-models/PdbView.ts @@ -11,6 +11,7 @@ import { BlockDef } from "../components/protvista/Protvista.types"; import { Tooltip } from "../components/protvista/Tooltip"; import { trackDefinitions } from "../../domain/definitions/tracks"; import { getBlockTracks } from "../components/protvista/Protvista.helpers"; +import { Interval } from "../../domain/entities/Fragment2"; import { Source } from "../../domain/entities/Source"; // https://github.com/ebi-webcomponents/nightingale/tree/master/packages/protvista-track @@ -79,9 +80,19 @@ export function getPdbView( const { block, showAllTracks = false, chainId, sources } = options; const data = showAllTracks ? pdb.tracks : getBlockTracks(pdb.tracks, block); + const structureCoverage = data.find( + track => track.id === trackDefinitions.structureCoverage.id + ); + const structureCoverageSubtrack = structureCoverage && _.first(structureCoverage.subtracks); + + const alignment: Interval[] = + structureCoverageSubtrack?.locations.flatMap(({ fragments }) => + fragments.map(({ start, end }) => ({ start, end })) + ) ?? []; + const tracks = _(data) .map((pdbTrack): TrackView | undefined => { - const subtracks = getSubtracks(pdb, pdbTrack, sources); + const subtracks = getSubtracks(pdb, pdbTrack, alignment, sources); if (_.isEmpty(subtracks)) return undefined; return { @@ -125,13 +136,23 @@ function getVariants(pdb: Pdb): VariantsView | undefined { }; } -function getSubtracks(pdb: Pdb, track: Track, sources: Source[]): TrackView["data"] { +function getSubtracks( + pdb: Pdb, + track: Track, + alignment: Interval[], + sources: Source[] +): TrackView["data"] { return _.flatMap(track.subtracks, subtrack => { - return hasFragments(subtrack) ? [getSubtrack(pdb, subtrack, sources)] : []; + return hasFragments(subtrack) ? [getSubtrack(pdb, subtrack, alignment, sources)] : []; }); } -function getSubtrack(pdb: Pdb, subtrack: Subtrack, sources: Source[]): SubtrackView { +function getSubtrack( + pdb: Pdb, + subtrack: Subtrack, + alignment: Interval[], + sources: Source[] +): SubtrackView { const label = subtrack.subtype ? `[${subtrack.subtype.name}] ${subtrack.label}` : subtrack.label; @@ -151,7 +172,7 @@ function getSubtrack(pdb: Pdb, subtrack: Subtrack, sources: Source[]): SubtrackV ...fragment, color: fragment.color || "black", tooltipContent: renderToString( - React.createElement(Tooltip, { pdb, subtrack, fragment, sources }) + React.createElement(Tooltip, { pdb, subtrack, fragment, alignment, sources }) ), })), })),