diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 8d1d7653..6acf81b8 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -11,11 +11,73 @@ jobs: uses: actions/setup-node@v3 with: node-version: '16.14.0' + - name: Check versions + run: | + main_version=$(grep -E '^# 3DBIONOTES-WS v[0-9.]+' README.md | sed -E 's/^# 3DBIONOTES-WS v([0-9.]+)$/\1/') + viewer_protvista_version=$(cat app/views/webserver/viewer.html.haml | grep -Eo 'protvista-pdb-[0-9.]+-est-[0-9]+(-beta.[0-9]+){0,1}' | awk '{print $1}') + viewer_pdbe_molstar_version=$(cat app/views/webserver/viewer.html.haml | grep -Eo 'pdbe-molstar-plugin-[0-9.]+-est-[0-9]+(-beta.[0-9]+){0,1}' | awk '{print $1}') + cd app/assets/javascripts/covid19 + bio_covid19_version=$(cat package.json | jq -r '.version') + cd ../3dbio_viewer + bio_viewer_version=$(cat package.json | jq -r '.version') + protvista_version=$(cat package.json | jq -r '.dependencies["@3dbionotes/protvista-pdb"]') + pdbe_molstar_version=$(cat package.json | jq -r '.dependencies["@3dbionotes/pdbe-molstar"]') + index_protvista_version=$(cat public/index.html | grep -Eo 'protvista-pdb-[0-9.]+-est-[0-9]+(-beta.[0-9]+){0,1}' | awk '{print $1}') + index_pdbe_molstar_version=$(cat public/index.html | grep -Eo 'pdbe-molstar-plugin-[0-9.]+-est-[0-9]+(-beta.[0-9]+){0,1}' | awk '{print $1}') + if [ "$bio_viewer_version" != "$bio_covid19_version" ] || + [ "$main_version" != "$bio_covid19_version" ]; then + echo "3dbio_viewer version doesn't match with covid19 version" + echo "3dbio_viewer: $bio_viewer_version" + echo "covid19: $bio_covid19_version" + echo "README.md version: $main_version" + exit 1 + fi + + if [ "$viewer_protvista_version" != "$index_protvista_version" ] || + [ "$viewer_pdbe_molstar_version" != "$index_pdbe_molstar_version" ] || + [ "$(echo "$index_protvista_version" | sed 's/protvista-pdb-//')" != "$protvista_version" ] || + [ "$(echo "$index_pdbe_molstar_version" | sed 's/pdbe-molstar-plugin-//')" != "$pdbe_molstar_version" ]; then + echo "Versions don't match:" + echo "Viewer and index protvista: $viewer_protvista_version, $index_protvista_version" + echo "Viewer and index pdbe-molstar: $viewer_pdbe_molstar_version, $index_pdbe_molstar_version" + echo "3dbio_viewer dependency and index protvista: $protvista_version, $(echo "$index_protvista_version" | sed 's/protvista-pdb-//')" + echo "3dbio_viewer dependency and index pdbe-molstar: $pdbe_molstar_version, $(echo "$index_pdbe_molstar_version" | sed 's/pdbe-molstar-plugin-//'), " + exit 1 + fi + + echo "3dbio_viewer versions match" + echo "README.md version: $main_version" + echo "3DBIONOTES version: $bio_viewer_version" + echo "protvista-pdb version: $protvista_version" + echo "pdbe-molstar version: $pdbe_molstar_version" - name: Install and build (3dbio_viewer) run: | cd app/assets/javascripts/3dbio_viewer yarn install yarn build + - name: Check build dependency versions (3dbioviewer) + run: | + cd app/assets/javascripts/3dbio_viewer + index_protvista_version=$(cat public/index.html | grep -Eo 'protvista-pdb-[0-9.]+-est-[0-9]+(-beta.[0-9]+){0,1}' | awk '{print $1}') + index_pdbe_molstar_version=$(cat public/index.html | grep -Eo 'pdbe-molstar-plugin-[0-9.]+-est-[0-9]+(-beta.[0-9]+){0,1}' | awk '{print $1}') + build_pdbe_molstar_file="build/pdbe-molstar/$index_pdbe_molstar_version.js" + if [ -f "$build_pdbe_molstar_file" ]; then + echo "Build file $build_pdbe_molstar_file exists." + else + echo "Build file $build_pdbe_molstar_file does not exist." + exit 1 + fi + + build_protvista_file="build/protvista-pdb/$index_protvista_version.js" + if [ -f "$build_protvista_file" ]; then + echo "Build file $build_protvista_file exists." + else + echo "Build file $build_protvista_file does not exist." + exit 1 + fi + - name: Run tests (3dbio_viewer) + run: | + cd app/assets/javascripts/3dbio_viewer yarn test:nowatch - name: Install and build (covid19) run: | diff --git a/app/assets/javascripts/3dbio_viewer/package.json b/app/assets/javascripts/3dbio_viewer/package.json index 7200677c..8f8db445 100644 --- a/app/assets/javascripts/3dbio_viewer/package.json +++ b/app/assets/javascripts/3dbio_viewer/package.json @@ -114,4 +114,4 @@ "\\.(jpg|jpeg|png|svg)$": "/config/fileMock.js" } } -} \ No newline at end of file +} 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 8c9c69c0..eac0fa6b 100644 --- a/app/assets/javascripts/3dbio_viewer/src/data/repositories/BionotesPdbInfoRepository.ts +++ b/app/assets/javascripts/3dbio_viewer/src/data/repositories/BionotesPdbInfoRepository.ts @@ -8,52 +8,55 @@ 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 i18n from "../../domain/utils/i18n"; export class BionotesPdbInfoRepository implements PdbInfoRepository { get(pdbId: PdbId): FutureData { - const proteinMappingUrl = `${routes.bionotes}/api/mappings/PDB/Uniprot/${pdbId}`; + const proteinMappingUrl = `${routes.ebi}/pdbe/api/mappings/uniprot/${pdbId}`; const fallbackMappingUrl = `${routes.ebi}/pdbe/api/pdb/entry/polymer_coverage/${pdbId}/`; const emdbMapping = `${emdbsFromPdbUrl}/${pdbId}`; const data$ = { - uniprotMapping: getFromUrl(proteinMappingUrl).flatMapError((err) => buildError("serviceUnavailable", err)), - fallbackMapping: getFromUrl(fallbackMappingUrl).flatMapError((err) => buildError("noData", err)), + uniprotMapping: getFromUrl>( + proteinMappingUrl + ).flatMapError(_err => Future.success, Error>(undefined)), + fallbackMapping: getFromUrl(fallbackMappingUrl).flatMapError(err => + buildError("noData", err) + ), emdbMapping: getFromUrl(emdbMapping), }; return Future.joinObj(data$).flatMap(data => { const { uniprotMapping, emdbMapping, fallbackMapping } = data; - const proteinsMapping = uniprotMapping[pdbId.toLowerCase()]; + const proteinsMapping = uniprotMapping && uniprotMapping[pdbId.toLowerCase()]?.UniProt; const fallback = fallbackMapping[pdbId.toLowerCase()]; - if (!proteinsMapping) { + if (!proteinsMapping && !fallback) { const err = `Uniprot mapping not found for ${pdbId}`; return buildError("noData", { message: err }); } - if (!fallback) { - const err = `No fallback chains found for ${pdbId}`; - return buildError("noData", { message: err }); - } - const emdbIds = getEmdbsFromMapping(emdbMapping, pdbId); - if (proteinsMapping instanceof Array) { - return Future.success(buildPdbInfo({ - id: pdbId, - emdbs: emdbIds.map(emdbId => ({ id: emdbId })), - ligands: [], - proteins: [], - proteinsMapping: undefined, - chains: fallback.molecules.flatMap(({ chains }) => chains).map(chain => ({ - id: chain.struct_asym_id, - shortName: chain.struct_asym_id, - name: chain.struct_asym_id, - chainId: chain.struct_asym_id, - protein: undefined - })) - })); - } + if (!proteinsMapping && fallback) + return Future.success( + buildPdbInfo({ + id: pdbId, + emdbs: emdbIds.map(emdbId => ({ id: emdbId })), + ligands: [], + proteins: [], + proteinsMapping: undefined, + chains: fallback.molecules + .flatMap(({ chains }) => chains) + .map(chain => ({ + id: chain.struct_asym_id, + shortName: chain.struct_asym_id, + name: chain.struct_asym_id, + chainId: chain.struct_asym_id, + protein: undefined, + })), + }) + ); const proteins = _(proteinsMapping).keys().join(","); const proteinsInfoUrl = `${routes.bionotes}/api/lengths/UniprotMulti/${proteins}`; @@ -61,6 +64,13 @@ export class BionotesPdbInfoRepository implements PdbInfoRepository { ? getFromUrl(proteinsInfoUrl) : Future.success({}); + const proteinsMappingChains = _.mapValues(proteinsMapping, v => + v.mappings.map(({ struct_asym_id, chain_id }) => ({ + structAsymId: struct_asym_id, + chainId: chain_id, + })) + ); + return proteinsInfo$.map(proteinsInfo => { const proteins = _(proteinsInfo) .toPairs() @@ -78,7 +88,7 @@ export class BionotesPdbInfoRepository implements PdbInfoRepository { ligands: [], chains: [], proteins, - proteinsMapping, + proteinsMapping: proteinsMappingChains, }); }); }); @@ -90,22 +100,35 @@ type ErrorType = "serviceUnavailable" | "noData"; function buildError(type: ErrorType, err: RequestError): FutureData { console.error(err.message); switch (type) { - case "serviceUnavailable": return Future.error({ - message: i18n.t( - "We apologize. Some of the services we rely on are temporarily unavailable. Our team is working to resolve the issue, and we appreciate your patience. Please try again later." - ), - }); - case "noData": return Future.error({ - message: i18n.t(`No data found for this PDB. But you can try and visualize another PDB. If you believe this is incorrect, please contact us using the "Send Feedback" button below.`), - }) + case "serviceUnavailable": + return Future.error({ + message: i18n.t( + "We apologize. Some of the services we rely on are temporarily unavailable. Our team is working to resolve the issue, and we appreciate your patience. Please try again later." + ), + }); + case "noData": + return Future.error({ + message: i18n.t( + `No data found for this PDB. But you can try and visualize another PDB. If you believe this is incorrect, please contact us using the "Send Feedback" button below.` + ), + }); } } -type UniprotFromPdbMapping = Record | unknown[]>; +export type UniprotMapping = Record< + ProteinId, + { mappings: { chain_id: ChainId; struct_asym_id: ChainId }[] } +>; + +type Uniprot = { + UniProt: UniprotMapping; +}; + +type UniprotFromPdbMapping = Record; type PolymerMolecules = { chains: { struct_asym_id: string }[]; -}[] +}[]; type ChainsFromPolymer = Record; 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 bd96e96e..59e028ab 100644 --- a/app/assets/javascripts/3dbio_viewer/src/domain/entities/PdbInfo.ts +++ b/app/assets/javascripts/3dbio_viewer/src/domain/entities/PdbInfo.ts @@ -2,7 +2,7 @@ import _ from "lodash"; import { Maybe } from "../../utils/ts-utils"; import { Ligand } from "./Ligand"; import { Emdb } from "./Pdb"; -import { ChainId, Protein, ProteinId } from "./Protein"; +import { ChainId, Protein } from "./Protein"; import { UploadData } from "./UploadData"; export interface PdbInfo { @@ -19,9 +19,14 @@ type Chain = { protein: Maybe; }; +type ChainIds = { + structAsymId: string; + chainId: string; +}; + interface BuildPdbInfoOptions extends PdbInfo { proteins: Protein[]; - proteinsMapping: Maybe>; + proteinsMapping: Maybe>; } export function buildPdbInfo(options: BuildPdbInfoOptions): PdbInfo { @@ -33,13 +38,13 @@ export function buildPdbInfo(options: BuildPdbInfoOptions): PdbInfo { const protein = proteinById[proteinId]; if (!protein) return []; - return chainIds.map(chainId => { - const shortName = _([chainId, protein.gen]).compact().join(" - "); + return chainIds.map(({ structAsymId, chainId: _chainId }) => { + const shortName = _([structAsymId, protein.gen]).compact().join(" - "); return { - id: [proteinId, chainId].join("-"), + id: [proteinId, structAsymId].join("-"), shortName, name: _([shortName, protein.name]).compact().join(", "), - chainId, + chainId: structAsymId, protein, }; }); 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 fe9c9d78..e219d361 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,33 +1,84 @@ import React from "react"; import _ from "lodash"; +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 } from "../../../domain/entities/Fragment2"; import { Pdb } from "../../../domain/entities/Pdb"; import { Subtrack } from "../../../domain/entities/Track"; -import i18n from "../../utils/i18n"; import { renderJoin } from "../../utils/react"; import { Link } from "../Link"; import { Protein } from "../../../domain/entities/Protein"; +import { trackDefinitions } from "../../../domain/definitions/tracks"; +import i18n from "../../utils/i18n"; interface TooltipProps { pdb: Pdb; subtrack: Subtrack; fragment: FragmentP; + alignment: Interval[]; } type FragmentP = Fragment | Fragment2; export const Tooltip: React.FC = React.memo(props => { - const { pdb, subtrack, fragment } = props; + const { pdb, subtrack, fragment, alignment } = props; const { description, alignmentScore } = fragment; - const score = alignmentScore ? alignmentScore + " %" : undefined; + const score = alignmentScore ? alignmentScore + " %" : undefined; // aligmentScore is never being set on code + const isStructureCoverage = subtrack.accession === trackDefinitions.structureCoverage.id; + + // Intervals inside intervals (Spanish): https://chat.openai.com/share/eb5816e2-d5c5-455c-bf6e-9e08e8850470 + const isCovered = + isStructureCoverage || + alignment.some( + interval => fragment.start >= interval.start && fragment.end <= interval.end + ); + + const isPartiallyCovered = alignment.some( + interval => fragment.start <= interval.end && fragment.end >= interval.start + ); + + const isNotCovered = alignment.every( + interval => fragment.start > interval.end || fragment.end < interval.start + ); return ( - + {isNotCovered && ( + + {info => ( + + )} + + )} + {isPartiallyCovered && !isCovered && ( + + {info => ( + + )} + + )} + - + @@ -171,3 +222,10 @@ const ReferencesRows: React.FC<{ title?: string; sources: Reference[] }> = props ); }; + +const InfoIcon: React.FC<{ title: string }> = props => ( + + {props.title} + {props.children} + +); 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 860ebef0..02fe8344 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/view-models/PdbView.ts b/app/assets/javascripts/3dbio_viewer/src/webapp/view-models/PdbView.ts index 701f0299..0c547072 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"; // https://github.com/ebi-webcomponents/nightingale/tree/master/packages/protvista-track @@ -78,9 +79,19 @@ export function getPdbView( const { block, showAllTracks = false, chainId } = 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); + const subtracks = getSubtracks(pdb, pdbTrack, alignment); if (_.isEmpty(subtracks)) return undefined; return { @@ -124,13 +135,13 @@ function getVariants(pdb: Pdb): VariantsView | undefined { }; } -function getSubtracks(pdb: Pdb, track: Track): TrackView["data"] { +function getSubtracks(pdb: Pdb, track: Track, alignment: Interval[]): TrackView["data"] { return _.flatMap(track.subtracks, subtrack => { - return hasFragments(subtrack) ? [getSubtrack(pdb, subtrack)] : []; + return hasFragments(subtrack) ? [getSubtrack(pdb, subtrack, alignment)] : []; }); } -function getSubtrack(pdb: Pdb, subtrack: Subtrack): SubtrackView { +function getSubtrack(pdb: Pdb, subtrack: Subtrack, alignment: Interval[]): SubtrackView { const label = subtrack.subtype ? `[${subtrack.subtype.name}] ${subtrack.label}` : subtrack.label; @@ -150,7 +161,7 @@ function getSubtrack(pdb: Pdb, subtrack: Subtrack): SubtrackView { ...fragment, color: fragment.color || "black", tooltipContent: renderToString( - React.createElement(Tooltip, { pdb, subtrack, fragment }) + React.createElement(Tooltip, { pdb, subtrack, fragment, alignment }) ), })), })),