diff --git a/src/frontend/src/pages/analytical-page/AnalyticalPage.tsx b/src/frontend/src/pages/analytical-page/AnalyticalPage.tsx index 1db780a..81c0a5a 100644 --- a/src/frontend/src/pages/analytical-page/AnalyticalPage.tsx +++ b/src/frontend/src/pages/analytical-page/AnalyticalPage.tsx @@ -184,9 +184,19 @@ function AnalyticalPage() { const isPollingOffForGood = useRef(false); // if true, polling is turned off and won't be turned on again // querySeqMapping[idxFrom before aligning] -> idxTo after aligning (to master query seq) const querySeqMapping = useRef>({}); + const isFirstRender = useRef(true); // used to disable turning on polling when user visits analytical page for the first time + // Number of data source executors that did not fail and returned result + const [numOfDseWhichReturnedResult, setNumOfDseWhichReturnedResult] = useState(null); useEffect(() => { - if (isPollingOffForGood.current) { + if (isFirstRender.current) { + /* When user visits the analytical page for the first time, it turns on the polling automatically, + * we don't want it, thus this if exists to prevent it. Polling will be started if initial data fetch + * doesn not fetch all data. */ + isFirstRender.current = false; + return; + } + if (isFirstRender.current || isPollingOffForGood.current) { /* If initial data fetching/polling is finished for every data source, we don't want to turn it on again. * That is why we have this if here. * Moreover, we don't have to set pollingInterval to null here (in this if), because @@ -197,15 +207,24 @@ function AnalyticalPage() { }, [isPageVisible]); useEffect(() => { + async function sleep(timeoutInSeconds: number) { + await new Promise(f => setTimeout(f, 1000 * timeoutInSeconds)); + } + async function initChains() { + /* Timeout is set also before even trying to get the chains, + * the reason is that if client is faster than server, it might try to get file with chains before server + * created it. To avoid pointlessly displaying toast about this, we simply wait a bit before asking for the chains. */ + const timeoutInSeconds = 0.5; + await sleep(timeoutInSeconds); + let chainsInitialized = false; while (!chainsInitialized) { const { chains: chainsTmp, errMsg: allChainsFetchingErrorMessage } = await tryGetChains(); if (allChainsFetchingErrorMessage.length > 0) { - const timeoutInSeconds = 0.5; console.warn(allChainsFetchingErrorMessage + `\nMaybe file was not created yet? Retrying in ${timeoutInSeconds} second(s)...`); - await new Promise(f => setTimeout(f, 1000 * timeoutInSeconds)); // Sleep + await sleep(timeoutInSeconds); continue; } chains.current = chainsTmp; @@ -237,7 +256,8 @@ function AnalyticalPage() { useEffect(() => { async function stopPollingAndAlignSequences(defaultChain: string) { - // Turn off polling entirely for all data sources (to be precise, this turns off useInterval) + /* Turn off polling entirely for all data sources (to be precise, this turns off useInterval). + * (isPollingOffForGood was set to true already when allDataFetched was set to true, so no need to set it again here.) */ setPollingInterval(null); // Aligning will take place in the following function @@ -262,6 +282,13 @@ function AnalyticalPage() { useInterval(() => { for (let dataSourceExecutorIdx = 0; dataSourceExecutorIdx < dataSourceExecutors.current.length; dataSourceExecutorIdx++) { + if (!chains.current[0]) { + /* This should never happen, but let's keep this if due to defensive programming. + * Maybe polling started by accident sooner? If that's the case, chains should be set any second now, + * so let's just continue. */ + console.warn("Chains not set yet."); + continue; + } if (isFetching.current[dataSourceExecutorIdx] || isFetchingFinished.current[dataSourceExecutorIdx]) { continue; } @@ -346,14 +373,15 @@ function AnalyticalPage() { )}
- {currChainResult && selectedChain && selectedChain in bindingSiteSupportCounter ? (<> + {currChainResult && selectedChain && selectedChain in bindingSiteSupportCounter && numOfDseWhichReturnedResult !== null ? (<>
setIsMolstarLoadingStructures(true)} @@ -503,6 +531,22 @@ function AnalyticalPage() { return startPart + alignedPart + endPart; } + /** + * Creates a mapping from a gapless amino acid sequence to its gapped version. + * + * This function aligns the `sequenceWithoutGaps` to `sequenceWithGaps` (which contains `-` for gaps) + * and returns a mapping from each residue index in the gapless sequence to its corresponding + * index in the gapped sequence. + * + * Assumes that both sequences correspond to the same original sequence, + * just with and without gaps. Every residue in the gapless sequence must exist in the gapped one + * in the same order, with optional `-` characters interleaved. + * + * @param sequenceWithoutGaps - The amino acid sequence without any gap characters. + * @param sequenceWithGaps - The aligned sequence with possible gap (`-`) characters. + * @returns A mapping object where each key is the index in the gapless sequence, and + * the corresponding value is the index in the gapped sequence. + */ function createMapping(sequenceWithoutGaps: string, sequenceWithGaps: string) { // key: idx in original seq without gaps, value: idx in query seq with gaps const mapping: Record = {}; @@ -524,6 +568,13 @@ function AnalyticalPage() { return mapping; } + /** + * Updates the residue indices in a binding site based on a provided mapping. + * + * @param bindingSite - The binding site object containing residues. + * @param mapping - A mapping from one sequence indices to another sequence indices (mapping[fromIdx] -> toIdx). + * @returns void + */ function updateBindingSiteResiduesIndices(bindingSite: BindingSite, mapping: Record) { for (let i = 0; i < bindingSite.residues.length; i++) { if (bindingSite.residues[i].sequenceIndex === undefined) { @@ -535,6 +586,19 @@ function AnalyticalPage() { } } + /** + * Calculates the average conservation score for residues within a given binding site. + * + * It filters the provided conservation data to include only those entries that correspond + * to the residue indices in the binding site. Then, it computes the average of the + * conservation values. + * + * If no matching conservation values are found, the function returns 0. + * + * @param bindingSite - The binding site containing residues. + * @param conservations - An array of conservation scores, each associated with a residue index. + * @returns The average conservation score for the binding site (0 if no data available). + */ function getAvgConservationForQueryBindingSite(bindingSite: BindingSite, conservations: Conservation[]) { const bindingSiteConservations = conservations.filter(c => bindingSite.residues.some(r => r.sequenceIndex === c.index)); @@ -596,6 +660,39 @@ function AnalyticalPage() { similarProtein.alignmentData.similarSequence = similarSeq; } + /** + * Aligns a query protein sequence with multiple similar protein sequences across various data sources. + * + * This function performs a multi-phase alignment pipeline: + * + * 1. **Preprocessing Phase**: + * - Aligns each similar protein with the query sequence using alignment metadata. + * - Updates aligned sequences and binding site residue indices. + * + * 2. **Merge Phase**: + * - Constructs a "master" query sequence that integrates all aligned versions (with gaps). + * - Aligns similar proteins to the master query sequence. + * - Builds two residue index mappings: + * - From original query sequence to master sequence (used for binding site alignment). + * - From each similar protein to master sequence (used for binding site alignment/remapping). + * + * 3. **Postprocessing Phase**: + * - Updates binding site residue indices based on alignment mappings. + * - Calculates residue support (how many data sources support each residue being part of a binding site). + * - Computes average conservation values for binding sites, if enabled. + * + * @param unprocessedResultPerDataSourceExecutor - A mapping of data source names to their unaligned query protein results. + * @param selectedSimilarProteins - A mapping of data source names to arrays of similar protein objects with alignment data. + * @param conservations - Array of conservation data for residues in the query sequence. + * @param chain - The protein chain identifier (used to store residue support). + * @param querySeqToStrMapping - Mapping from query sequence indices to structure indices. + * + * @returns A fully aligned `ChainResult` containing: + * - `querySequence`: the master query sequence (with gaps), + * - `querySeqToStrMapping`: structure mapping (unchanged), + * - `dataSourceExecutorResults`: processed results with aligned similar proteins, + * - `conservations`: updated conservation indices mapped to the master sequence. + */ function alignSequencesAcrossAllDataSources( unprocessedResultPerDataSourceExecutor: Record, selectedSimilarProteins: Record, @@ -605,8 +702,14 @@ function AnalyticalPage() { ): ChainResult { // unprocessedResultPerDataSourceExecutor[dataSourceName] -> UnprocessedResult const dataSourceExecutorsCount = Object.keys(unprocessedResultPerDataSourceExecutor).length; + // bindingSiteSupportCounterTmp[residue index in structure (of pocket)]: number of data sources supporting pocket on the index + const bindingSiteSupportCounterTmp: Record = {}; if (dataSourceExecutorsCount == 0) { // if we dont have any result from any data source executor, then we have nothing to align + setBindingSiteSupportCounter(prevState => ({ + ...prevState, + [chain]: bindingSiteSupportCounterTmp + })); return { querySequence: "", querySeqToStrMapping: {}, dataSourceExecutorResults: {}, conservations: [] }; } @@ -791,8 +894,6 @@ function AnalyticalPage() { /* "Postprocessing phase": Update all residue indices of each binding site, seq to struct mappings, * also count how many data sources support certain binding site and calculate avg conservations if required. */ - // bindingSiteSupportCounterTmp[residue index in structure (of pocket)]: number of data sources supporting pocket on the index - const bindingSiteSupportCounterTmp: Record = {}; for (const [dataSourceName, result] of Object.entries(unprocessedResultPerDataSourceExecutor)) { let supporterCounted: Record = {}; // one data source can support residue just once @@ -880,6 +981,9 @@ function AnalyticalPage() { const unalignedSimProtsTmp: Record = {}; for (const dse of dataSourceExecutors) { + if (!dse.result) { + continue; // No result for this data source executor (executor probably failed), so skip it. + } const unprocessedResultWithoutSimilarProteins: UnalignedResult = { id: dse.result.id, sequence: dse.result.sequence, @@ -902,8 +1006,16 @@ function AnalyticalPage() { unalignedResult.current = unalignedResultTmp; unalignedSimProts.current = unalignedSimProtsTmp; + setNumOfDseWhichReturnedResult(Object.keys(unalignedResultTmp).length); } + /** + * Aligns selected similar protein sequences to the query protein for a given chain. + * + * @param options - Array of user-selected similar proteins for alignment. + * @param chain - The chain identifier of the query protein to align against. + * @returns The alignment result for the specified chain. + */ function alignSequences(options: StructureOption[], chain: string) { setCurrChainResult(null); // Get selected sim prots @@ -1238,6 +1350,7 @@ function AnalyticalPage() { isMolstarLinkedToRcsb.current = true; } + /** Downloads data of selected query protein chain and selected similar proteins in JSON format. */ async function downloadData() { function getTimestamp() { const now = new Date(); diff --git a/src/frontend/src/pages/analytical-page/components/MolstarWrapper.tsx b/src/frontend/src/pages/analytical-page/components/MolstarWrapper.tsx index f538a3d..bfd4ab1 100644 --- a/src/frontend/src/pages/analytical-page/components/MolstarWrapper.tsx +++ b/src/frontend/src/pages/analytical-page/components/MolstarWrapper.tsx @@ -65,7 +65,7 @@ type Props = { selectedStructures: StructureOption[]; // bindingSiteSupportCounter[residue index in structure (of pocket)] -> number of data sources supporting that the residue index is part of binding site bindingSiteSupportCounter: Record; - dataSourceCount: number; + dataSourceCount: number; // ATTENTION! Count of data sources with result (i.e., ones which did not fail and provided result). // queryProteinBindingSitesData[dataSourceName][chain][bindingSiteId] -> true/false to show bindings site (and ligands if available) queryProteinBindingSitesData: Record>>; // similarProteinBindingSitesData[dataSourceName][pdbCode][chain][bindingSiteId] -> true/false to show bindings site (and ligands if available) @@ -342,7 +342,7 @@ export const MolStarWrapper = forwardRef(({ function getPocketTransparency(supportersCount: number | null = null) { const defaultValue = 1; // No transparency - if (!supportersCount) { + if (!supportersCount || dataSourceCount === 0) { return defaultValue; } @@ -575,6 +575,22 @@ export const MolStarWrapper = forwardRef(({ return inOptions; } + /** + * Performs dynamic structural superposition of a query protein with selected similar proteins. + * + * This function: + * - Loads the 3D structure of the query protein and selected similar proteins from URLs. + * - Parses and prepares expressions for protein binding sites and ligands (both for the query and similar proteins). + * - Aligns and superposes structures based on matching chains. + * - Builds and renders 3D representations for protein chains, binding pockets, and ligands. + * + * @param plugin - Mol* PluginContext used for structure loading, rendering, and transformation. + * @param format - The file format used to load structures (e.g. "pdb"). + * @param chain - The chain ID of the query protein to use for superposition. + * @param options - The list of user-selected similar protein structure options to include in the alignment. + * + * @returns A Promise resolving after all structures are loaded and aligned. + */ function performDynamicSuperposition(plugin: PluginContext, format: BuiltInTrajectoryFormat, chain: string, options: StructureOption[]) { return plugin.dataTransaction(async () => { // Load query protein structure diff --git a/src/frontend/src/pages/analytical-page/components/RcsbSaguaro.tsx b/src/frontend/src/pages/analytical-page/components/RcsbSaguaro.tsx index c9b0fc8..93ecb84 100644 --- a/src/frontend/src/pages/analytical-page/components/RcsbSaguaro.tsx +++ b/src/frontend/src/pages/analytical-page/components/RcsbSaguaro.tsx @@ -200,7 +200,7 @@ const RcsbSaguaro = forwardRef(({ } if (d.label) { const positionData: RcsbPositionData = JSON.parse(d.label); - if (positionData.position) { + if (positionData.position !== undefined) { tooltipHtml += `Position: ${positionData.position}`; } if (positionData.residue) { @@ -209,13 +209,13 @@ const RcsbSaguaro = forwardRef(({ if (squashBindingSites && positionData.bindingSiteId) { tooltipHtml += ` | Name: ${toBindingSiteLabel(positionData.bindingSiteId)}`; } - if (positionData.confidence) { + if (positionData.confidence !== undefined) { tooltipHtml += ` | Probability: ${positionData.confidence.toFixed(2)}`; } if (positionData.dataSourceName) { tooltipHtml += ` | Source: ${dataSourceDisplayNames[positionData.dataSourceName]}`; } - if (positionData.conservationValue) { + if (positionData.conservationValue !== undefined) { tooltipHtml += ` | Value: ${positionData.conservationValue.toFixed(2)}`; } } diff --git a/src/frontend/src/pages/home/components/InputSequenceBlock.tsx b/src/frontend/src/pages/home/components/InputSequenceBlock.tsx index 385054f..bf8ba70 100644 --- a/src/frontend/src/pages/home/components/InputSequenceBlock.tsx +++ b/src/frontend/src/pages/home/components/InputSequenceBlock.tsx @@ -58,7 +58,8 @@ function InputSequenceBlock({ data, setData, setErrorMessage, maxSequenceLength title="PlankWeb will use ESMFold predicted structure." onInput={updateHighlight} onFocus={() => setIsSequenceInputFocused(true)} - onBlur={() => setIsSequenceInputFocused(false)}> + onBlur={() => setIsSequenceInputFocused(false)} + onKeyDown={handleKeyDown}>
@@ -166,6 +167,23 @@ function InputSequenceBlock({ data, setData, setErrorMessage, maxSequenceLength setCursorPosition(newCursorPos); } } + + function handleKeyDown(event: React.KeyboardEvent) { + if (event.key === "Enter") { + event.preventDefault(); // Prevent default newline in contentEditable + trySubmit(); + } + } + + function trySubmit() { + /* Because contentEditable div is used here for highlighting "too-long-part" of the sequence, + * the usual behavior of user submitting by pressing Enter does not work here. That is why + * the submit button from parent component (form) is selected here and clicked programmatically. */ + const submitButton = document.getElementById("submit-button") as HTMLButtonElement | null; + if (submitButton && !submitButton.disabled) { + submitButton.click(); + } + } } export default InputSequenceBlock; diff --git a/src/frontend/src/pages/home/components/InputUserFileBlock.tsx b/src/frontend/src/pages/home/components/InputUserFileBlock.tsx index edad57e..6e23c7d 100644 --- a/src/frontend/src/pages/home/components/InputUserFileBlock.tsx +++ b/src/frontend/src/pages/home/components/InputUserFileBlock.tsx @@ -21,6 +21,8 @@ type Props = { }; function InputUserFileBlock({ data, setData, setErrorMessage }: Props) { + /* The following state variable is used to enable feature which allows user, after typing "space" into chains input field, + * to write comma (',') instead of space (' '). Writing "space" to the field again, turns off this "space mode". */ const [useSpaceAsComma, setUseSpaceAsComma] = useState(false); return ( @@ -39,7 +41,7 @@ function InputUserFileBlock({ data, setData, setErrorMessage }: Props) { id="user-file-chains" name="userFileChains" placeholder="A,B" - title="Optional. Comma separated list of chains to analyze." + title="Optional. Comma separated list of chains to analyze. (Type 'SPACE' to enable typing commas using space.)" value={data.chains} onChange={e => setData({ ...data, chains: sanitizeChainsWrapper(e.target.value) })} />
diff --git a/src/frontend/src/pages/home/components/QueryProteinForm.tsx b/src/frontend/src/pages/home/components/QueryProteinForm.tsx index 7087ad5..3149c45 100644 --- a/src/frontend/src/pages/home/components/QueryProteinForm.tsx +++ b/src/frontend/src/pages/home/components/QueryProteinForm.tsx @@ -95,6 +95,7 @@ function QueryProteinForm() { )}
+ {/* Don't change id of the submit button, the input sequence block selects it by its id to enable submit by enter. */}