/* eslint-disable class-methods-use-this */
import { html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import NightingaleElement, {
withHighlight,
withManager,
customElementOnce,
} from "@nightingale-elements/nightingale-new-core";
import { getStructureViewer, StructureViewer } from "./structure-viewer";
import translatePositions, {
PositionMappingError,
Mapping,
TranslatedPosition,
} from "./position-mapping";
/*
TODO:
[ ] Molstar/Mol* data fetching optimizations - create query to fetch only what is needed from model server, caching https://www.ebi.ac.uk/panda/jira/browse/TRM-26073
[ ] Molstar/Mol* bundle optimizations - only load the plugins that are absolutely needed https://www.ebi.ac.uk/panda/jira/browse/TRM-26074
[ ] Change highlight color in Mol* https://www.ebi.ac.uk/panda/jira/browse/TRM-26075
*/
export type StructureData = {
dbReferences: {
type: "PDB" | string;
id: string;
properties: {
method: string;
chains: string;
resolution: string;
};
}[];
};
export type PDBData = Record<
string,
{
UniProt: Record<
string,
{
identifier: string;
name: string;
mappings: Mapping[];
}
>;
}
>;
export type AlphaFoldPayload = Array<{
modelEntityId: string;
toolUsed?: string;
providerId?: string;
entityType?: string;
isUniProt?: boolean;
modelCreatedDate?: Date;
sequenceVersionDate?: Date;
globalMetricValue?: number;
fractionPlddtVeryLow?: number;
fractionPlddtLow?: number;
fractionPlddtConfident?: number;
fractionPlddtVeryHigh?: number;
latestVersion?: number;
allVersions?: number[];
sequence?: string;
sequenceStart?: number;
sequenceEnd?: number;
sequenceChecksum?: string;
isUniProtReviewed?: boolean;
gene?: string;
uniprotAccession?: string;
uniprotId?: string;
uniprotDescription?: string;
taxId?: number;
organismScientificName?: string;
isUniProtReferenceProteome?: boolean;
bcifUrl?: string;
cifUrl?: string;
pdbUrl?: string;
paeImageUrl?: string;
msaUrl?: string;
plddtDocUrl?: string;
paeDocUrl?: string;
amAnnotationsUrl?: string;
amAnnotationsHg19Url?: string;
amAnnotationsHg38Url?: string;
}>;
const uniProtMappingUrl = "https://www.ebi.ac.uk/pdbe/api/mappings/uniprot/";
const alphaFoldMappingUrl = "https://alphafold.ebi.ac.uk/api/prediction/";
@customElementOnce("nightingale-structure")
class NightingaleStructure extends withManager(
withHighlight(NightingaleElement)
) {
@property({ type: String })
"protein-accession"?: string;
@property({ type: String })
"structure-id"?: string;
@property({ type: String })
"custom-download-url"?: string;
@property({ type: String })
"model-url"?: string;
@property({ type: String })
"color-theme"?: string;
@state()
selectedMolecule?: {
id: string;
mappings?: Mapping[];
};
@state()
message?: { title: string; content: string } | null = {
title: "title",
content: "message",
};
#structureViewer?: StructureViewer;
constructor() {
super();
this.updateHighlight = this.updateHighlight.bind(this);
}
protected override render() {
return html`
${this.message && this["structure-id"]
? html`
${this.message?.title}:
`
: nothing}
`;
}
protected override firstUpdated() {
const structureViewerDiv =
this.renderRoot.querySelector("#molstar-parent");
if (structureViewerDiv) {
getStructureViewer(
structureViewerDiv,
this.updateHighlight,
this["color-theme"]
).then((structureViewer) => {
this.#structureViewer = structureViewer;
// Remove initial "#" and possible trailing opacity value
const color = this["highlight-color"].substring(1, 7);
this.#structureViewer.changeHighlightColor(parseInt(color, 16));
this.selectMolecule();
});
}
}
protected override updated(
changedProperties: Map
): void {
if (
changedProperties.has("structure-id") ||
changedProperties.has("model-url")
) {
this.selectMolecule();
}
if (
changedProperties.has("highlight") ||
changedProperties.has("selectedMolecule")
) {
this.highlightChain();
}
if (changedProperties.has("highlight-color")) {
// Remove initial "#" and possible trailing opacity value
const color = this["highlight-color"].substring(1, 7);
this.#structureViewer?.changeHighlightColor(parseInt(color, 16));
this.#structureViewer?.plugin.handleResize();
}
if (changedProperties.has("color-theme")) {
this.#structureViewer?.applyColorTheme(this["color-theme"]);
}
}
override disconnectedCallback() {
super.disconnectedCallback();
// Clean up
this.#structureViewer?.plugin.dispose();
}
async loadPDBEntry(pdbId: string): Promise {
this.#structureViewer?.plugin.clear();
this.showMessage("Loading", pdbId);
try {
return await fetch(`${uniProtMappingUrl}${pdbId}`).then((r) => r.json());
} catch (e) {
this.showMessage("Error", `Couldn't load PDB entry "${pdbId}"`);
throw e;
}
}
async loadAFEntry(id: string): Promise {
this.#structureViewer?.plugin.clear();
this.showMessage("Loading", id);
try {
return await fetch(`${alphaFoldMappingUrl}${id}`).then((r) => r.json());
} catch (e) {
this.showMessage("Error", `Couldn't load AF entry "${id}"`);
throw e;
}
}
isAF(): boolean | undefined {
return this["structure-id"]?.startsWith("AF-");
}
// Use the url above for testing
async selectMolecule(): Promise {
if (this["structure-id"] && this["model-url"]) {
console.error(
"Structure ID and Model URL both are present. Provide only one that you would want to take precedence"
);
return;
}
// if (
// !this["structure-id"] ||
// !this["protein-accession"]
// ) {
// return;
// }
let mappings;
if (this["structure-id"] && this["protein-accession"]) {
if (this.isAF()) {
const afPredictions = await this.loadAFEntry(this["protein-accession"]);
const afInfo = afPredictions.find(
(prediction) => prediction.modelEntityId === this["structure-id"]
);
// Note: maybe use bcif instead of cif, but I have issues loading it atm
if (afInfo?.cifUrl) {
await this.#structureViewer?.loadFromUrl(afInfo.cifUrl, false);
this.clearMessage();
}
// mappings = await this.#structureViewer.loadAF(afPredictions.b);
} else {
const pdbEntry = await this.loadPDBEntry(this["structure-id"]);
if (pdbEntry) {
mappings =
Object.values(pdbEntry)[0].UniProt[this["protein-accession"]]
?.mappings;
if (this["custom-download-url"]) {
await this.#structureViewer?.loadFromUrl(
`${this["custom-download-url"]}${this[
"structure-id"
].toLowerCase()}.cif`
);
this.clearMessage();
} else {
await this.#structureViewer?.loadPdb(
this["structure-id"].toLowerCase()
);
this.clearMessage();
}
}
}
this.selectedMolecule = {
id: this["structure-id"],
mappings,
};
}
if (this["model-url"]) {
this.#structureViewer?.plugin.clear();
await this.#structureViewer?.loadFromUrl(this["model-url"]);
this.clearMessage();
}
}
private showMessage(title: string, content: string, timeoutMs?: number) {
const message = { title, content };
this.message = message;
if (timeoutMs) {
setTimeout(() => {
if (this.message === message) {
this.message = null;
}
}, timeoutMs);
}
}
private clearMessage() {
this.message = null;
}
updateHighlight(
sequencePositions: { chain: string; position: number }[]
): void {
// sequencePositions assumed to be in PDB coordinate space
if (
!sequencePositions?.length ||
sequencePositions.some((pos) => !Number.isInteger(pos.position))
) {
return;
}
let translated: TranslatedPosition[];
if (this.isAF()) {
translated = sequencePositions.map((pos) => ({
start: pos.position,
end: pos.position,
entity: 1,
chain: pos.chain,
}));
} else {
try {
translated = sequencePositions
.flatMap((pos) =>
translatePositions(
pos.position,
pos.position,
"PDB_UP",
this.selectedMolecule?.mappings
).filter((t) => t.chain === pos.chain)
)
.filter(Boolean);
} catch (error) {
if (error instanceof PositionMappingError) {
this.showMessage("Error", error.message);
return;
}
throw error;
}
}
if (!translated.length) {
this.showMessage("Error", "Residue outside of sequence range");
return;
}
const highlight = translated
.map((residue) => `${residue.start}:${residue.end}`)
.join(",");
this.highlight = highlight;
const tooltip = translated
.map((residue) => {
const proteinPosition =
residue.start === residue.end
? residue.start
: `${residue.start}:${residue.end}`;
return `Chain ${residue.chain}
${this["protein-accession"]}: ${proteinPosition}`;
})
.join("");
this.showMessage(`${this["structure-id"]}`, tooltip);
const event = new CustomEvent("change", {
detail: {
highlight,
},
bubbles: true,
cancelable: true,
});
this.dispatchEvent(event);
}
highlightChain(): void {
if (!this.highlight) {
this.#structureViewer?.clearHighlight();
return;
}
let translatedPositions;
try {
translatedPositions = this.highlightedRegion.segments
.flatMap(({ start, end }) => {
if (this.isAF()) {
return {
start,
end,
chain: "A",
};
}
return translatePositions(
start,
end,
"UP_PDB",
this.selectedMolecule?.mappings
);
})
.filter(Boolean);
} catch (error) {
if (error instanceof PositionMappingError) {
this.#structureViewer?.clearHighlight();
this.showMessage("Error", error.message);
return;
}
throw error;
}
if (!translatedPositions?.length) {
this.#structureViewer?.clearHighlight();
return;
}
this.#structureViewer?.highlight(translatedPositions);
}
}
export default NightingaleStructure;