Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 121 additions & 8 deletions src/frontend/src/pages/analytical-page/AnalyticalPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,19 @@ function AnalyticalPage() {
const isPollingOffForGood = useRef<boolean>(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<Record<number, number>>({});
const isFirstRender = useRef<boolean>(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<number | null>(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
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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;
}
Expand Down Expand Up @@ -346,14 +373,15 @@ function AnalyticalPage() {
)}
</div>
<div id="visualization-molstar">
{currChainResult && selectedChain && selectedChain in bindingSiteSupportCounter ? (<>
{currChainResult && selectedChain && selectedChain in bindingSiteSupportCounter && numOfDseWhichReturnedResult !== null ? (<>
<div className="w-100 d-flex justify-content-center align-items-center mb-2 px-4">
<MolStarWrapper ref={molstarWrapperRef}
chainResult={currChainResult}
selectedChain={selectedChain}
selectedStructures={selectedStructures}
bindingSiteSupportCounter={bindingSiteSupportCounter[selectedChain]}
dataSourceCount={dataSourceExecutors.current.length}
// Count of data sources that actually returned results should be provided
dataSourceCount={numOfDseWhichReturnedResult}
queryProteinBindingSitesData={queryProteinBindingSitesData}
similarProteinBindingSitesData={similarProteinBindingSitesData}
onStructuresLoadingStart={() => setIsMolstarLoadingStructures(true)}
Expand Down Expand Up @@ -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<number, number> = {};
Expand All @@ -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<number, number>) {
for (let i = 0; i < bindingSite.residues.length; i++) {
if (bindingSite.residues[i].sequenceIndex === undefined) {
Expand All @@ -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));
Expand Down Expand Up @@ -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<string, UnalignedResult>,
selectedSimilarProteins: Record<string, UnalignedSimilarProtein[]>,
Expand All @@ -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<number, number> = {};
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: [] };
}

Expand Down Expand Up @@ -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<number, number> = {};
for (const [dataSourceName, result] of Object.entries(unprocessedResultPerDataSourceExecutor)) {
let supporterCounted: Record<number, boolean> = {}; // one data source can support residue just once

Expand Down Expand Up @@ -880,6 +981,9 @@ function AnalyticalPage() {
const unalignedSimProtsTmp: Record<string, UnalignedSimilarProtein[]> = {};

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,
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, number>;
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<string, Record<string, Record<string, boolean>>>;
// similarProteinBindingSitesData[dataSourceName][pdbCode][chain][bindingSiteId] -> true/false to show bindings site (and ligands if available)
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 += `<strong>Position:</strong> ${positionData.position}`;
}
if (positionData.residue) {
Expand All @@ -209,13 +209,13 @@ const RcsbSaguaro = forwardRef(({
if (squashBindingSites && positionData.bindingSiteId) {
tooltipHtml += ` | <strong>Name:</strong> ${toBindingSiteLabel(positionData.bindingSiteId)}`;
}
if (positionData.confidence) {
if (positionData.confidence !== undefined) {
tooltipHtml += ` | <strong>Probability:</strong> ${positionData.confidence.toFixed(2)}`;
}
if (positionData.dataSourceName) {
tooltipHtml += ` | <strong>Source:</strong> ${dataSourceDisplayNames[positionData.dataSourceName]}`;
}
if (positionData.conservationValue) {
if (positionData.conservationValue !== undefined) {
tooltipHtml += ` | <strong>Value:</strong> ${positionData.conservationValue.toFixed(2)}`;
}
}
Expand Down
20 changes: 19 additions & 1 deletion src/frontend/src/pages/home/components/InputSequenceBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}>
</div>
</div>
<div className="form-check">
Expand Down Expand Up @@ -166,6 +167,23 @@ function InputSequenceBlock({ data, setData, setErrorMessage, maxSequenceLength
setCursorPosition(newCursorPos);
}
}

function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
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;
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>(false);

return (
Expand All @@ -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) })} />
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ function QueryProteinForm() {
)}
</div>
<div>
{/* Don't change id of the submit button, the input sequence block selects it by its id to enable submit by enter. */}
<button id="submit-button"
type="submit"
className="btn btn-primary float-right"
Expand Down
9 changes: 9 additions & 0 deletions src/frontend/src/shared/helperFunctions/errorHandling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ const _getErrorMessages = (error: any) => {
return errorMessages;
};

/**
* Extracts and normalizes error messages from an Axios error object.
*
* Handles various formats of error responses (arrays, objects, raw strings), ensures all returned messages are strings,
* and appends "Unknown error occurred." for any non-string entries.
*
* @param error - The error object thrown during an Axios request.
* @returns An array of string error messages.
*/
export const getErrorMessages = (error: any) => {
const originalErrorMessages = _getErrorMessages(error);
const originalErrorMessagesCount = originalErrorMessages.length;
Expand Down
Loading