diff --git a/projects/pathway-browser/src/app/details/common/external-reference/external-reference.component.ts b/projects/pathway-browser/src/app/details/common/external-reference/external-reference.component.ts index bb1479c..1910825 100644 --- a/projects/pathway-browser/src/app/details/common/external-reference/external-reference.component.ts +++ b/projects/pathway-browser/src/app/details/common/external-reference/external-reference.component.ts @@ -1,18 +1,18 @@ -import {Component, computed, input} from '@angular/core'; -import {ReferenceEntity} from "../../../model/graph/reference-entity/reference-entity.model"; -import {EntityService} from "../../../services/entity.service"; -import {isArray, isString} from "lodash"; -import {NgClass, TitleCasePipe} from "@angular/common"; -import {StructureViewerComponent} from "../../tabs/molecule-tab/structure-viewer/structure-viewer.component"; -import {DatabaseIdentifier} from "../../../model/graph/database-identifier.model"; -import {MatIconButton} from "@angular/material/button"; -import {MatIcon} from "@angular/material/icon"; -import {UrlStateService} from "../../../services/url-state.service"; -import {MatTooltip} from "@angular/material/tooltip"; -import {DataStateService} from "../../../services/data-state.service"; -import {Labels} from "../../../constants/constants"; -import {StructureService} from "../../../services/structure.service"; -import {MoleculeType} from "../../tabs/molecule-tab/molecule-tab.component"; +import { Component, computed, input } from '@angular/core'; +import { ReferenceEntity } from '../../../model/graph/reference-entity/reference-entity.model'; +import { EntityService } from '../../../services/entity.service'; +import { isArray, isString } from 'lodash'; +import { NgClass, TitleCasePipe } from '@angular/common'; +import { StructureViewerComponent } from '../../tabs/molecule-tab/structure-viewer/structure-viewer.component'; +import { DatabaseIdentifier } from '../../../model/graph/database-identifier.model'; +import { MatIconButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { UrlStateService } from '../../../services/url-state.service'; +import { MatTooltip } from '@angular/material/tooltip'; +import { DataStateService } from '../../../services/data-state.service'; +import { Labels } from '../../../constants/constants'; +import { StructureService } from '../../../services/structure.service'; +import { MoleculeType } from '../../tabs/molecule-tab/molecule-tab.component'; @Component({ selector: 'cr-external-reference', @@ -25,11 +25,10 @@ import {MoleculeType} from "../../tabs/molecule-tab/molecule-tab.component"; MatIconButton, MatTooltip, NgClass, - StructureViewerComponent - ] + StructureViewerComponent, + ], }) export class ExternalReferenceComponent { - readonly referenceEntity = input.required(); readonly xRefs = input([]); @@ -42,22 +41,28 @@ export class ExternalReferenceComponent { moleculeType = computed(() => { const entity = this.referenceEntity(); return entity ? entity.moleculeType : null; - }) - - hasStructure = computed(() => this.moleculeType() === MoleculeType.PROTEIN || this.moleculeType() === MoleculeType.CHEMICAL); + }); + hasStructure = computed(() => + [ + MoleculeType.CHEMICAL, + MoleculeType.CHEMICAL_DRUG, + MoleculeType.PROTEIN, + ].includes(this.moleculeType() as MoleculeType) + ); - constructor(private entity: EntityService, - private state: UrlStateService, - public data: DataStateService, - private structure: StructureService) { - } + constructor( + private entity: EntityService, + private state: UrlStateService, + public data: DataStateService, + private structure: StructureService + ) {} protected readonly isString = isString; protected readonly isArray = isArray; onSelect() { - this.state.select.set(this.referenceEntity().stId!) + this.state.select.set(this.referenceEntity().stId!); } protected readonly Labels = Labels; diff --git a/projects/pathway-browser/src/app/details/details.component.html b/projects/pathway-browser/src/app/details/details.component.html index c6bffad..72ed7ed 100644 --- a/projects/pathway-browser/src/app/details/details.component.html +++ b/projects/pathway-browser/src/app/details/details.component.html @@ -25,7 +25,7 @@

📋 Reactome Knowledgebase Details

Loading details of {{ state.select() || state.pathwayId() }}

} @else if (obj()) { - + } diff --git a/projects/pathway-browser/src/app/details/tabs/description-tab/description-tab.component.ts b/projects/pathway-browser/src/app/details/tabs/description-tab/description-tab.component.ts index fd5321e..4194ab2 100644 --- a/projects/pathway-browser/src/app/details/tabs/description-tab/description-tab.component.ts +++ b/projects/pathway-browser/src/app/details/tabs/description-tab/description-tab.component.ts @@ -8,10 +8,10 @@ import { signal, Signal, TemplateRef, - viewChild + viewChild, } from '@angular/core'; -import type {Analysis} from "../../../model/analysis.model"; -import {IconService} from "../../../services/icon.service"; +import type { Analysis } from '../../../model/analysis.model'; +import { IconService } from '../../../services/icon.service'; import { getProperty, groupAndSortBy, @@ -21,64 +21,61 @@ import { isReferenceSequence, isReferenceSummary, isRLE, - observeSections -} from "../../../services/utils"; -import {DatabaseObject} from "../../../model/graph/database-object.model"; -import {ReferenceEntity} from "../../../model/graph/reference-entity/reference-entity.model"; -import {rxResource} from "@angular/core/rxjs-interop"; -import {InstanceEdit} from "../../../model/graph/instance-edit.model"; -import {LiteratureReference} from "../../../model/graph/publication/literature-reference.model"; -import {SelectableObject} from "../../../services/event.service"; -import {of} from "rxjs"; -import {PhysicalEntity} from "../../../model/graph/physical-entity/physical-entity.model"; -import {InteractorService} from "../../../interactors/services/interactor.service"; -import {EntityService} from "../../../services/entity.service"; -import {DataKeys, Labels} from "../../../constants/constants"; -import {CatalystActivity} from "../../../model/graph/catalyst-activity.model"; -import {CatalystActivityReference} from "../../../model/graph/control-reference/catalyst-activity-reference.model"; -import {Regulation} from "../../../model/graph/Regulation/regulation.model"; -import {RegulationReference} from "../../../model/graph/control-reference/regulation-reference.model"; -import type {Relationship} from "../../../model/graph/relationship.model"; -import {DatabaseIdentifier} from "../../../model/graph/database-identifier.model"; + observeSections, +} from '../../../services/utils'; +import { DatabaseObject } from '../../../model/graph/database-object.model'; +import { ReferenceEntity } from '../../../model/graph/reference-entity/reference-entity.model'; +import { rxResource } from '@angular/core/rxjs-interop'; +import { InstanceEdit } from '../../../model/graph/instance-edit.model'; +import { LiteratureReference } from '../../../model/graph/publication/literature-reference.model'; +import { SelectableObject } from '../../../services/event.service'; +import { of } from 'rxjs'; +import { PhysicalEntity } from '../../../model/graph/physical-entity/physical-entity.model'; +import { InteractorService } from '../../../interactors/services/interactor.service'; +import { EntityService } from '../../../services/entity.service'; +import { DataKeys, Labels } from '../../../constants/constants'; +import { CatalystActivity } from '../../../model/graph/catalyst-activity.model'; +import { CatalystActivityReference } from '../../../model/graph/control-reference/catalyst-activity-reference.model'; +import { Regulation } from '../../../model/graph/Regulation/regulation.model'; +import { RegulationReference } from '../../../model/graph/control-reference/regulation-reference.model'; +import type { Relationship } from '../../../model/graph/relationship.model'; +import { DatabaseIdentifier } from '../../../model/graph/database-identifier.model'; +import { EntityWithAccessionedSequence } from '../../../model/graph/physical-entity/entity-with-accessioned-sequence.model'; +import { MarkerReference } from '../../../model/graph/control-reference/marker-reference.model'; +import { camelCase, isArray } from 'lodash'; +import { UrlStateService } from '../../../services/url-state.service'; import { - EntityWithAccessionedSequence -} from "../../../model/graph/physical-entity/entity-with-accessioned-sequence.model"; -import {MarkerReference} from "../../../model/graph/control-reference/marker-reference.model"; -import {camelCase, isArray} from "lodash"; -import {UrlStateService} from "../../../services/url-state.service"; -import {CONTENT_DETAIL, environment} from "../../../../environments/environment"; -import {SpeciesService} from "../../../services/species.service"; -import {Summation} from "../../../model/graph/summation.model"; -import {FigureService} from "./figure/figure.service"; -import {KeyValuePipe, NgClass, NgTemplateOutlet} from "@angular/common"; -import {RouterLink} from "@angular/router"; -import {SortByTextPipe} from "../../../pipes/sort-by-text.pipe"; -import {IncludeRefPipe} from "../../../pipes/include-ref.pipe"; -import {AuthorshipDateFormatPipe} from "../../../pipes/authorship-date-format.pipe"; -import {MatDivider} from "@angular/material/divider"; -import {MatIcon} from "@angular/material/icon"; -import {MatTooltip} from "@angular/material/tooltip"; -import {MatSlideToggle} from "@angular/material/slide-toggle"; -import {FormsModule} from "@angular/forms"; -import {MatAnchor} from "@angular/material/button"; -import {DescriptionOverviewComponent} from "./description-overview/description-overview.component"; -import {RefsTreeComponent} from "../../common/refs-tree/refs-tree.component"; -import {PublicationComponent} from "../../common/publication/publication.component"; -import {CrossReferencesComponent} from "../../common/cross-references/cross-references.component"; -import {ExternalReferenceComponent} from "../../common/external-reference/external-reference.component"; -import {ControllerTreeComponent} from "../../common/controller-tree/controller-tree.component"; -import {MolecularProcessComponent} from "../../common/molecular-process/molecular-process.component"; -import {CellMarkerComponent} from "../../common/cell-marker/cell-marker.component"; -import {IconComponent} from "./icon/icon.component"; -import {RheaComponent} from "../../common/rhea/rhea.component"; -import {InteractorsTableComponent} from "../../common/interactors-table/interactors-table.component"; -import { - LocationsTreeComponent -} from "../../../../../../website-angular/src/app/content/detail/locations-tree/locations-tree.component"; -import {ReactionDiagramComponent} from "../../common/reaction-diagram/reaction-diagram.component"; - + CONTENT_DETAIL, + environment, +} from '../../../../environments/environment'; +import { SpeciesService } from '../../../services/species.service'; +import { Summation } from '../../../model/graph/summation.model'; +import { FigureService } from './figure/figure.service'; type HasModifiedResidue = Relationship.HasModifiedResidue; - +import { KeyValuePipe, NgClass, NgTemplateOutlet } from '@angular/common'; +import { RouterLink } from '@angular/router'; +import { SortByTextPipe } from '../../../pipes/sort-by-text.pipe'; +import { IncludeRefPipe } from '../../../pipes/include-ref.pipe'; +import { AuthorshipDateFormatPipe } from '../../../pipes/authorship-date-format.pipe'; +import { MatDivider } from '@angular/material/divider'; +import { MatIcon } from '@angular/material/icon'; +import { MatTooltip } from '@angular/material/tooltip'; +import { MatSlideToggle } from '@angular/material/slide-toggle'; +import { FormsModule } from '@angular/forms'; +import { MatAnchor } from '@angular/material/button'; +import { DescriptionOverviewComponent } from './description-overview/description-overview.component'; +import { RefsTreeComponent } from '../../common/refs-tree/refs-tree.component'; +import { PublicationComponent } from '../../common/publication/publication.component'; +import { CrossReferencesComponent } from '../../common/cross-references/cross-references.component'; +import { ExternalReferenceComponent } from '../../common/external-reference/external-reference.component'; +import { ControllerTreeComponent } from '../../common/controller-tree/controller-tree.component'; +import { MolecularProcessComponent } from '../../common/molecular-process/molecular-process.component'; +import { CellMarkerComponent } from '../../common/cell-marker/cell-marker.component'; +import { IconComponent } from './icon/icon.component'; +import { RheaComponent } from '../../common/rhea/rhea.component'; +import { InteractorsTableComponent } from '../../common/interactors-table/interactors-table.component'; +import { LocationsTreeComponent } from '../../../../../../website-angular/src/app/content/detail/locations-tree/locations-tree.component'; +import { ReactionDiagramComponent } from '../../common/reaction-diagram/reaction-diagram.component'; @Component({ selector: 'cr-description-tab', @@ -111,11 +108,10 @@ type HasModifiedResidue = Relationship.HasModifiedResidue; RheaComponent, InteractorsTableComponent, LocationsTreeComponent, - ReactionDiagramComponent - ] + ReactionDiagramComponent, + ], }) export class DescriptionTabComponent implements OnDestroy { - private iconService: IconService = inject(IconService); private entity: EntityService = inject(EntityService); public figure: FigureService = inject(FigureService); @@ -124,35 +120,49 @@ export class DescriptionTabComponent implements OnDestroy { private species: SpeciesService = inject(SpeciesService); icon = rxResource({ request: () => this.referenceEntity()?.identifier, - loader: (param) => param.request ? this.iconService.fetchIcon(param.request) : of(null) - }) + loader: (param) => + param.request ? this.iconService.fetchIcon(param.request) : of(null), + }); - readonly figures = computed(() => (this.obj().figure || []).filter(f => !f.url.includes('ehld'))); - readonly hasIllustration = computed(() => this.figures().length > 0 || this.icon.hasValue()); + readonly figures = computed(() => + (this.obj().figure || []).filter((f) => !f.url.includes('ehld')) + ); + readonly hasIllustration = computed( + () => this.figures().length > 0 || this.icon.hasValue() + ); currentIcon = this.iconService.currentIcon; _otherForms = rxResource({ - request: () => isPhysicalEntity(this.obj()) && !isReferenceSummary(this.obj()) && this.referenceEntity() && this.obj().stId, - loader: (param) => param.request ? this.entity.getOtherForms(param.request) : of(null) - }) + request: () => + isPhysicalEntity(this.obj()) && + !isReferenceSummary(this.obj()) && + this.referenceEntity() && + this.obj().stId, + loader: (param) => + param.request ? this.entity.getOtherForms(param.request) : of(null), + }); _interactors = rxResource({ - request: () => isPhysicalEntity(this.obj()) && this.referenceEntity()?.identifier, - loader: (param) => param.request ? this.interactorService.getCustomInteractorsByAcc(param.request) : of(null) - }) + request: () => + isPhysicalEntity(this.obj()) && this.referenceEntity()?.identifier, + loader: (param) => + param.request + ? this.interactorService.getCustomInteractorsByAcc(param.request) + : of(null), + }); readonly obj = input.required(); readonly analysisResult = input(); readonly showLocations = input(false); + readonly showReactionDiagram = input(true); static referenceTypeToNameSuffix = new Map([ - ["ReferenceMolecule", ""], - ["ReferenceGeneProduct", ""], - ["ReferenceDNASequence", " Gene"], - ["ReferenceRNASequence", " mRNA"], - ["ReferenceTherapeutic", " Drug"] - ] - ); + ['ReferenceMolecule', ''], + ['ReferenceGeneProduct', ''], + ['ReferenceDNASequence', ' Gene'], + ['ReferenceRNASequence', ' mRNA'], + ['ReferenceTherapeutic', ' Drug'], + ]); readonly isReferenceSummary = computed(() => isReferenceSummary(this.obj())); @@ -166,124 +176,202 @@ export class DescriptionTabComponent implements OnDestroy { : obj.displayName; if (isReferenceSummary(obj)) { - const suffix = DescriptionTabComponent.referenceTypeToNameSuffix.get(obj.referenceEntity.schemaClass) + const suffix = DescriptionTabComponent.referenceTypeToNameSuffix.get( + obj.referenceEntity.schemaClass + ); if ( isReferenceSequence(obj.referenceEntity) && isDefinedAndNotEmpty(obj.referenceEntity.geneName) - ) name = obj.referenceEntity.geneName[0]; - return (obj.referenceEntity.schemaClass === 'ReferenceIsoform') ? `${name} Isoform ${obj.variantIdentifier} ` : name + suffix; + ) + name = obj.referenceEntity.geneName[0]; + return obj.referenceEntity.schemaClass === 'ReferenceIsoform' + ? `${name} Isoform ${obj.variantIdentifier} ` + : name + suffix; } - return name - }) + return name; + }); readonly symbol = computed(() => this.getSymbol(this.obj())); - readonly literatureRefs: Signal = computed(() => getProperty(this.obj(), DataKeys.LITERATURE_REFERENCE)); - readonly groupedReferences = computed(() => groupAndSortBy(this.literatureRefs(), ref => ref.year, (key1, key2) => key2 - key1)); + readonly literatureRefs: Signal = computed(() => + getProperty(this.obj(), DataKeys.LITERATURE_REFERENCE) + ); + readonly groupedReferences = computed(() => + groupAndSortBy( + this.literatureRefs(), + (ref) => ref.year, + (key1, key2) => key2 - key1 + ) + ); - readonly summations: Signal = computed(() => getProperty(this.obj(), DataKeys.SUMMATION)); + readonly summations: Signal = computed(() => + getProperty(this.obj(), DataKeys.SUMMATION) + ); readonly allRefs = computed(() => { const literatureRefs = this.literatureRefs(); - const summation = getProperty(this.obj(), DataKeys.SUMMATION) as Summation[]; - return [...literatureRefs || [], ...summation.flatMap((s) => s.literatureReference as LiteratureReference[]).filter(isDefined) || []] - }); - - - referenceEntity: Signal = computed(() => getProperty(this.obj(), DataKeys.REFERENCE_ENTITY)); - - readonly authorship: Signal<{label: string, data: InstanceEdit[]}[]> = computed(() => { - const arrayWrap = (a: E[] | E) => Array.isArray(a) ? a : [a]; - - const obj = this.obj(); - // Ensure it's an array, either returning the existing array or wrapping it in one, it complains without this line. - const authored = arrayWrap(getProperty(obj, DataKeys.AUTHORED) || []); - const reviewed = getProperty(obj, DataKeys.REVIEWED) || []; - const edited = getProperty(obj, DataKeys.EDITED) || []; - const revised = getProperty(obj, DataKeys.REVISED) || []; - const created = arrayWrap(getProperty(obj, DataKeys.CREATED) || []); - + const summation = getProperty( + this.obj(), + DataKeys.SUMMATION + ) as Summation[]; return [ - ...(authored.length > 0 ? [{label: Labels.AUTHOR, data: authored}] : []), - ...(reviewed.length > 0 ? [{label: Labels.REVIEWER, data: reviewed}] : []), - ...(edited.length > 0 ? [{label: Labels.EDITOR, data: edited}] : []), - ...(revised.length > 0 ? [{label: Labels.REVISER, data: revised}] : []), - ...(created.length > 0 ? [{label: 'Created', data: created}] : []), + ...(literatureRefs || []), + ...(summation + .flatMap((s) => s.literatureReference as LiteratureReference[]) + .filter(isDefined) || []), ]; }); + referenceEntity: Signal = computed(() => + getProperty(this.obj(), DataKeys.REFERENCE_ENTITY) + ); + + readonly authorship: Signal<{ label: string; data: InstanceEdit[] }[]> = + computed(() => { + const arrayWrap = (a: E[] | E) => (Array.isArray(a) ? a : [a]); + + const obj = this.obj(); + // Ensure it's an array, either returning the existing array or wrapping it in one, it complains without this line. + const authored = arrayWrap(getProperty(obj, DataKeys.AUTHORED) || []); + const reviewed = getProperty(obj, DataKeys.REVIEWED) || []; + const edited = getProperty(obj, DataKeys.EDITED) || []; + const revised = getProperty(obj, DataKeys.REVISED) || []; + const created = arrayWrap(getProperty(obj, DataKeys.CREATED) || []); + + return [ + ...(authored.length > 0 + ? [{ label: Labels.AUTHOR, data: authored }] + : []), + ...(reviewed.length > 0 + ? [{ label: Labels.REVIEWER, data: reviewed }] + : []), + ...(edited.length > 0 ? [{ label: Labels.EDITOR, data: edited }] : []), + ...(revised.length > 0 + ? [{ label: Labels.REVISER, data: revised }] + : []), + ...(created.length > 0 ? [{ label: 'Created', data: created }] : []), + ]; + }); + inferences = computed(() => { - const inferences: PhysicalEntity[] = getProperty(this.obj(), DataKeys.INFERRED_TO); + const inferences: PhysicalEntity[] = getProperty( + this.obj(), + DataKeys.INFERRED_TO + ); if (!inferences) return new Map(); return this.getGroupedInferences(inferences); }); - otherForms = computed(() => { const value = this._otherForms.value(); if (!value) return new Map(); return this.getGroupedOtherForms(value); - }) + }); interactors = computed(() => this._interactors.value() || []); interactorsLength = computed(() => this._interactors.value()?.length || 0); - catalystActivity: Signal = computed(() => getProperty(this.obj(), DataKeys.CATALYST_ACTIVITY)); - catalystActivities: Signal = computed(() => getProperty(this.obj(), DataKeys.CATALYST_ACTIVITIES)); - catalystRef: Signal = computed(() => getProperty(this.obj(), DataKeys.CATALYST_ACTIVITY_REFERENCE)); + catalystActivity: Signal = computed(() => + getProperty(this.obj(), DataKeys.CATALYST_ACTIVITY) + ); + catalystActivities: Signal = computed(() => + getProperty(this.obj(), DataKeys.CATALYST_ACTIVITIES) + ); + catalystRef: Signal = computed(() => + getProperty(this.obj(), DataKeys.CATALYST_ACTIVITY_REFERENCE) + ); - regulations: Signal = computed(() => getProperty(this.obj(), DataKeys.REGULATED_BY)); - regulationRefs: Signal = computed(() => getProperty(this.obj(), DataKeys.REGULATION_REFERENCE)); + regulations: Signal = computed(() => + getProperty(this.obj(), DataKeys.REGULATED_BY) + ); + regulationRefs: Signal = computed(() => + getProperty(this.obj(), DataKeys.REGULATION_REFERENCE) + ); regulates: Signal = computed(() => [ ...(getProperty(this.obj(), DataKeys.POSITIVELY_REGULATES) || []), ...(getProperty(this.obj(), DataKeys.NEGATIVELY_REGULATES) || []), ]); - modifications: Signal = computed(() => getProperty(this.obj(), DataKeys.MODIFIED_RESIDUES)); + modifications: Signal = computed(() => + getProperty(this.obj(), DataKeys.MODIFIED_RESIDUES) + ); crossReference = computed(() => { if (this.referenceEntity() && this.referenceEntity().crossReference) { return this.referenceEntity().crossReference; } - const crossReference: DatabaseIdentifier[] = getProperty(this.obj(), DataKeys.CROSS_REFERENCE); + const crossReference: DatabaseIdentifier[] = getProperty( + this.obj(), + DataKeys.CROSS_REFERENCE + ); return crossReference ? [...crossReference] : []; }); - proteinMarkers: Signal = computed(() => getProperty(this.obj(), DataKeys.PROTEIN_MARKER) || []) - rnaMarkers: Signal = computed(() => getProperty(this.obj(), DataKeys.RNA_MARKERS) || []) - markerReference: Signal = computed(() => getProperty(this.obj(), DataKeys.MARKER_REFERENCE)) + proteinMarkers: Signal = computed( + () => getProperty(this.obj(), DataKeys.PROTEIN_MARKER) || [] + ); + rnaMarkers: Signal = computed( + () => getProperty(this.obj(), DataKeys.RNA_MARKERS) || [] + ); + markerReference: Signal = computed(() => + getProperty(this.obj(), DataKeys.MARKER_REFERENCE) + ); - repeatedUnits: Signal = computed(() => getProperty(this.obj(), DataKeys.REPEATED_UNIT)) + repeatedUnits: Signal = computed(() => + getProperty(this.obj(), DataKeys.REPEATED_UNIT) + ); - hasRhea = computed(() => ["RHEA", "Rhea"].includes(this.crossReference()[0]?.databaseName)); + hasRhea = computed(() => + ['RHEA', 'Rhea'].includes(this.crossReference()[0]?.databaseName) + ); // Disable the navigation control for inferred event when there is no associated pathway // https://reactome.org/beta/PathwayBrowser/R-HSA-9931510?select=R-HSA-9909400&path=R-HSA-9909396#inferredFrom inferenceNavigationVisibility = computed(() => { - const isHuman = this.species.currentSpecies().taxId === this.species.defaultSpecies.taxId; + const isHuman = + this.species.currentSpecies().taxId === this.species.defaultSpecies.taxId; const isInferred = this.obj().isInferred; return isHuman && isRLE(this.obj()) && isInferred; - }) + }); overview$ = viewChild('overview'); overviewTemplate$ = viewChild.required>('overviewTemplate'); - referenceTemplate$ = viewChild.required>('referenceTemplate'); - modificationsTemplate$ = viewChild.required>('modificationsTemplate'); - crossReferencesTemplate$ = viewChild.required>('crossReferencesTemplate'); + referenceTemplate$ = + viewChild.required>('referenceTemplate'); + modificationsTemplate$ = viewChild.required>( + 'modificationsTemplate' + ); + crossReferencesTemplate$ = viewChild.required>( + 'crossReferencesTemplate' + ); markerTemplate$ = viewChild.required>('markerTemplate'); - regulationTemplate$ = viewChild.required>('regulationTemplate'); - regulatesTemplate$ = viewChild.required>('regulatesTemplate'); - catalystActivityTemplate$ = viewChild.required>('catalystActivityTemplate'); - catalystActivitiesTemplate$ = viewChild.required>('catalystActivitiesTemplate'); - inferencesTemplate$ = viewChild.required>('inferencesTemplate'); - otherFormsTemplate$ = viewChild.required>('otherFormsTemplate'); - literatureRefsTemplate$ = viewChild.required>('literatureRefsTemplate'); + regulationTemplate$ = + viewChild.required>('regulationTemplate'); + regulatesTemplate$ = + viewChild.required>('regulatesTemplate'); + catalystActivityTemplate$ = viewChild.required>( + 'catalystActivityTemplate' + ); + catalystActivitiesTemplate$ = viewChild.required>( + 'catalystActivitiesTemplate' + ); + inferencesTemplate$ = + viewChild.required>('inferencesTemplate'); + otherFormsTemplate$ = + viewChild.required>('otherFormsTemplate'); + literatureRefsTemplate$ = viewChild.required>( + 'literatureRefsTemplate' + ); authorsTemplate$ = viewChild.required>('authorsTemplate'); - interactorsTemplate$ = viewChild.required>('interactorsTemplate'); + interactorsTemplate$ = viewChild.required>( + 'interactorsTemplate' + ); rheaTemplate$ = viewChild.required>('rheaTemplate'); locationsTemplate$ = viewChild>('locationsTemplate'); - reactionDiagramTemplate$ = viewChild>('reactionDiagramTemplate'); + reactionDiagramTemplate$ = viewChild>( + 'reactionDiagramTemplate' + ); readonly isReaction = computed(() => isRLE(this.obj())); @@ -294,70 +382,100 @@ export class DescriptionTabComponent implements OnDestroy { private manualSelection = false; private observer?: () => void; - //todo get divider label from here elements: { - key: string, - label: string, - hasDepthControl?: boolean, - manual?: boolean, - scope?: 'entity' | 'event', - template?: Signal>, - isPresent?: Signal, - disableNavigation?: Signal + key: string; + label: string; + hasDepthControl?: boolean; + manual?: boolean; + scope?: 'entity' | 'event'; + template?: Signal>; + isPresent?: Signal; + disableNavigation?: Signal; }[] = [ { key: DataKeys.OVERVIEW, label: Labels.OVERVIEW, manual: true, template: this.overviewTemplate$, - isPresent: signal(true) + isPresent: signal(true), }, { key: 'locationsInPWB', label: 'Locations in the Pathway Browser', manual: true, template: this.locationsTemplate$ as Signal>, - isPresent: computed(() => this.showLocations()) + isPresent: computed(() => this.showLocations()), }, { key: 'reactionDiagram', label: 'Reaction Diagram', manual: true, template: this.reactionDiagramTemplate$ as Signal>, - isPresent: this.isReaction + isPresent: computed( + () => this.isReaction() && this.showReactionDiagram() + ), + }, + { + key: DataKeys.REFERENCE_ENTITY, + label: Labels.EXTERNAL_REFERENCE, + manual: true, + template: this.referenceTemplate$, }, - {key: DataKeys.REFERENCE_ENTITY, label: Labels.EXTERNAL_REFERENCE, manual: true, template: this.referenceTemplate$}, - {key: DataKeys.SUMMARISED_ENTITIES, label: Labels.SUMMARISED_ENTITIES}, + { key: DataKeys.SUMMARISED_ENTITIES, label: Labels.SUMMARISED_ENTITIES }, { key: DataKeys.MODIFIED_RESIDUES, label: Labels.MODIFIED_RESIDUES, manual: true, - template: this.modificationsTemplate$ + template: this.modificationsTemplate$, }, - {key: DataKeys.MEMBERS, label: Labels.MEMBERS, hasDepthControl: true}, - {key: DataKeys.CANDIDATES, label: Labels.CANDIDATES, hasDepthControl: true}, - {key: DataKeys.COMPONENTS, label: Labels.COMPONENTS, hasDepthControl: true}, - {key: DataKeys.REPEATED_UNIT, label: Labels.REPEATED_UNIT, hasDepthControl: true}, + { key: DataKeys.MEMBERS, label: Labels.MEMBERS, hasDepthControl: true }, + { + key: DataKeys.CANDIDATES, + label: Labels.CANDIDATES, + hasDepthControl: true, + }, + { + key: DataKeys.COMPONENTS, + label: Labels.COMPONENTS, + hasDepthControl: true, + }, + { + key: DataKeys.REPEATED_UNIT, + label: Labels.REPEATED_UNIT, + hasDepthControl: true, + }, { key: DataKeys.PROTEIN_MARKER, label: Labels.MARKERS, manual: true, template: this.markerTemplate$, - isPresent: computed(() => this.proteinMarkers().length + this.rnaMarkers().length > 0) + isPresent: computed( + () => this.proteinMarkers().length + this.rnaMarkers().length > 0 + ), }, - {key: DataKeys.EVENTS, label: Labels.EVENTS, hasDepthControl: true, scope: 'event'}, - {key: DataKeys.INPUT, label: Labels.INPUTS, hasDepthControl: true}, - {key: DataKeys.OUTPUT, label: Labels.OUTPUTS, hasDepthControl: true}, - {key: DataKeys.REGULATED_BY, label: Labels.REGULATED_BY, manual: true, template: this.regulationTemplate$}, + { + key: DataKeys.EVENTS, + label: Labels.EVENTS, + hasDepthControl: true, + scope: 'event', + }, + { key: DataKeys.INPUT, label: Labels.INPUTS, hasDepthControl: true }, + { key: DataKeys.OUTPUT, label: Labels.OUTPUTS, hasDepthControl: true }, + { + key: DataKeys.REGULATED_BY, + label: Labels.REGULATED_BY, + manual: true, + template: this.regulationTemplate$, + }, { key: DataKeys.CATALYST_ACTIVITIES, label: Labels.CATALYST_ACTIVITIES, manual: true, template: this.catalystActivitiesTemplate$, - isPresent: computed(() => this.catalystActivities()?.length > 0) + isPresent: computed(() => this.catalystActivities()?.length > 0), }, { @@ -365,7 +483,7 @@ export class DescriptionTabComponent implements OnDestroy { label: Labels.CATALYST_ACTIVITY, manual: true, template: this.catalystActivityTemplate$, - isPresent: computed(() => this.catalystActivity()?.length > 0) + isPresent: computed(() => this.catalystActivity()?.length > 0), }, { @@ -373,7 +491,9 @@ export class DescriptionTabComponent implements OnDestroy { label: Labels.CROSS_REFERENCES, manual: true, template: this.crossReferencesTemplate$, - isPresent: computed(() => this.crossReference()?.length > 0 && !this.hasRhea()) + isPresent: computed( + () => this.crossReference()?.length > 0 && !this.hasRhea() + ), }, // Rhea structure { @@ -381,61 +501,110 @@ export class DescriptionTabComponent implements OnDestroy { label: Labels.BIOCHEMICAL_REACTION, manual: true, template: this.rheaTemplate$, - isPresent: computed(() => this.hasRhea()) + isPresent: computed(() => this.hasRhea()), }, - {key: DataKeys.PRECEDING_EVENT, label: Labels.PRECEDING_EVENT, scope: 'event'}, - {key: DataKeys.FOLLOWING_EVENT, label: Labels.FOLLOWING_EVENT, scope: 'event'}, - {key: DataKeys.INPUT_FOR, label: Labels.INPUT_FOR}, - {key: DataKeys.OUTPUT_FOR, label: Labels.OUTPUT_FOR}, + { + key: DataKeys.PRECEDING_EVENT, + label: Labels.PRECEDING_EVENT, + scope: 'event', + }, + { + key: DataKeys.FOLLOWING_EVENT, + label: Labels.FOLLOWING_EVENT, + scope: 'event', + }, + { key: DataKeys.INPUT_FOR, label: Labels.INPUT_FOR }, + { key: DataKeys.OUTPUT_FOR, label: Labels.OUTPUT_FOR }, { key: DataKeys.REGULATES, label: Labels.REGULATES, manual: true, template: this.regulatesTemplate$, - isPresent: computed(() => this.regulates().length > 0) + isPresent: computed(() => this.regulates().length > 0), + }, + { + key: DataKeys.COMPONENT_OF, + label: Labels.COMPONENT_OF, + hasDepthControl: true, + }, + { key: DataKeys.MEMBER_OF, label: Labels.MEMBER_OF, hasDepthControl: true }, + { + key: DataKeys.CANDIDATE_OF, + label: Labels.CANDIDATE_OF, + hasDepthControl: true, + }, + { + key: DataKeys.NORMAL_REACTION, + label: Labels.NORMAL_REACTION, + hasDepthControl: true, + scope: 'event', + }, + { + key: DataKeys.NORMAL_PATHWAY, + label: Labels.NORMAL_PATHWAY, + hasDepthControl: true, + scope: 'event', + }, + { + key: DataKeys.EVENT_OF, + label: Labels.EVENT_OF, + hasDepthControl: true, + scope: 'event', + }, + { + key: DataKeys.DISEASE_PATHWAYS, + label: Labels.DISEASE_PATHWAYS, + hasDepthControl: true, + scope: 'event', + }, + { + key: DataKeys.DISEASE_REACTIONS, + label: Labels.DISEASE_REACTIONS, + hasDepthControl: true, + scope: 'event', }, - {key: DataKeys.COMPONENT_OF, label: Labels.COMPONENT_OF, hasDepthControl: true}, - {key: DataKeys.MEMBER_OF, label: Labels.MEMBER_OF, hasDepthControl: true}, - {key: DataKeys.CANDIDATE_OF, label: Labels.CANDIDATE_OF, hasDepthControl: true}, - {key: DataKeys.NORMAL_REACTION, label: Labels.NORMAL_REACTION, hasDepthControl: true, scope: 'event'}, - {key: DataKeys.NORMAL_PATHWAY, label: Labels.NORMAL_PATHWAY, hasDepthControl: true, scope: 'event'}, - {key: DataKeys.EVENT_OF, label: Labels.EVENT_OF, hasDepthControl: true, scope: 'event'}, - {key: DataKeys.DISEASE_PATHWAYS, label: Labels.DISEASE_PATHWAYS, hasDepthControl: true, scope: 'event'}, - {key: DataKeys.DISEASE_REACTIONS, label: Labels.DISEASE_REACTIONS, hasDepthControl: true, scope: 'event'}, - - {key: DataKeys.INFERRED_TO, label: Labels.INFERENCES, manual: true, template: this.inferencesTemplate$}, + { + key: DataKeys.INFERRED_TO, + label: Labels.INFERENCES, + manual: true, + template: this.inferencesTemplate$, + }, { key: DataKeys.INFERRED_FROM, label: Labels.INFERRED_FROM, - disableNavigation: computed(() => this.inferenceNavigationVisibility()) + disableNavigation: computed(() => this.inferenceNavigationVisibility()), }, { key: DataKeys.OTHER_FORMS, label: Labels.OTHER_FORMS, manual: true, template: this.otherFormsTemplate$, - isPresent: computed(() => this.otherForms()?.size > 0) + isPresent: computed(() => this.otherForms()?.size > 0), }, - {key: DataKeys.LITERATURE_REFERENCE, label: Labels.REFERENCE, manual: true, template: this.literatureRefsTemplate$}, + { + key: DataKeys.LITERATURE_REFERENCE, + label: Labels.REFERENCE, + manual: true, + template: this.literatureRefsTemplate$, + }, { key: camelCase(Labels.AUTHORSHIP), label: Labels.AUTHORSHIP, manual: true, template: this.authorsTemplate$, - isPresent: computed(() => this.authorship()?.length > 0) + isPresent: computed(() => this.authorship()?.length > 0), }, { key: DataKeys.INTERACTORS, label: Labels.INTERACTORS, manual: true, template: this.interactorsTemplate$, - isPresent: computed(() => this.interactorsLength() > 0) + isPresent: computed(() => this.interactorsLength() > 0), }, - ] - + ]; constructor() { effect(() => { @@ -446,9 +615,14 @@ export class DescriptionTabComponent implements OnDestroy { }); effect(() => { - const ids = this.elements.map(e => e.key); - this.observer = observeSections(ids, this.selectedKey, this.manualSelection, false) - }) + const ids = this.elements.map((e) => e.key); + this.observer = observeSections( + ids, + this.selectedKey, + this.manualSelection, + false + ); + }); } ngOnDestroy(): void { @@ -461,12 +635,12 @@ export class DescriptionTabComponent implements OnDestroy { // Group by species name getGroupedInferences(inferences: PhysicalEntity[]) { - return this.entity.getGroupedData(inferences, pe => pe.speciesName); + return this.entity.getGroupedData(inferences, (pe) => pe.speciesName); } // Group by compartment getGroupedOtherForms(otherForms: PhysicalEntity[]) { - return this.entity.getGroupedData(otherForms, pe => { + return this.entity.getGroupedData(otherForms, (pe) => { // Extract compartment (group name) from displayName => HSPA8 [plasma membrane] => plasma membrane return pe.displayName.match(/\[(.*?)\]/)?.[1] || pe.displayName; }); @@ -500,7 +674,6 @@ export class DescriptionTabComponent implements OnDestroy { } } - selectItem(key: string): void { this.manualSelection = true; this.selectedKey.set(key); @@ -509,7 +682,7 @@ export class DescriptionTabComponent implements OnDestroy { el?.scrollIntoView({ behavior: 'smooth', block: 'start', - inline: 'start' + inline: 'start', }); // allow observer updates again after scroll completes setTimeout(() => { diff --git a/projects/pathway-browser/src/app/details/tabs/molecule-tab/structure-viewer/structure-viewer.component.ts b/projects/pathway-browser/src/app/details/tabs/molecule-tab/structure-viewer/structure-viewer.component.ts index a38b1c0..79f5702 100644 --- a/projects/pathway-browser/src/app/details/tabs/molecule-tab/structure-viewer/structure-viewer.component.ts +++ b/projects/pathway-browser/src/app/details/tabs/molecule-tab/structure-viewer/structure-viewer.component.ts @@ -7,23 +7,22 @@ import { input, linkedSignal, signal, - viewChild + viewChild, } from '@angular/core'; -import {DatabaseIdentifier} from "../../../../model/graph/database-identifier.model"; -import {MatFormField, MatLabel} from "@angular/material/form-field"; -import {MatOptgroup, MatOption, MatSelect} from "@angular/material/select"; -import {rxResource} from "@angular/core/rxjs-interop"; -import {extract, Style} from "reactome-cytoscape-style"; -import {DarkService} from "../../../../services/dark.service"; -import {ReferenceEntity} from "../../../../model/graph/reference-entity/reference-entity.model"; -import {catchError, EMPTY, map} from "rxjs"; -import {HttpClient} from "@angular/common/http"; -import {SafePipe} from "../../../../pipes/safe.pipe"; -import {SelectableObject} from "../../../../services/event.service"; -import {MoleculeType} from "../molecule-tab.component"; -import {StructureService} from "../../../../services/structure.service"; -import {MatProgressSpinner} from "@angular/material/progress-spinner"; - +import { DatabaseIdentifier } from '../../../../model/graph/database-identifier.model'; +import { MatFormField, MatLabel } from '@angular/material/form-field'; +import { MatOptgroup, MatOption, MatSelect } from '@angular/material/select'; +import { rxResource } from '@angular/core/rxjs-interop'; +import { extract, Style } from 'reactome-cytoscape-style'; +import { DarkService } from '../../../../services/dark.service'; +import { ReferenceEntity } from '../../../../model/graph/reference-entity/reference-entity.model'; +import { catchError, EMPTY, map } from 'rxjs'; +import { HttpClient } from '@angular/common/http'; +import { SafePipe } from '../../../../pipes/safe.pipe'; +import { SelectableObject } from '../../../../services/event.service'; +import { MoleculeType } from '../molecule-tab.component'; +import { StructureService } from '../../../../services/structure.service'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; export interface StructureEntry { pdb_id: string; @@ -44,53 +43,53 @@ interface BestStructure { interface AlphaFoldSummary { uniprot_entry: { - ac: string, - id: string, - uniprot_checksum: string, - sequence_length: number, - segment_start: number, - segment_end: number - } + ac: string; + id: string; + uniprot_checksum: string; + sequence_length: number; + segment_start: number; + segment_end: number; + }; structures: { summary: { - model_identifier: string, - model_category: string, - model_url: string, - model_format: string, - model_type?: null, - model_page_url: string, - provider: string, - number_of_conformers?: number, - ensemble_sample_url?: string, - ensemble_sample_format?: string, - created: Date, - sequence_identity: number, - uniprot_start: number, - uniprot_end: number, - coverage: number, - experimental_method?: string, - resolution?: string, - confidence_type?: string, - confidence_version?: number, - confidence_avg_local_score: number, - oligomeric_state?: string, - preferred_assembly_id?: string, + model_identifier: string; + model_category: string; + model_url: string; + model_format: string; + model_type?: null; + model_page_url: string; + provider: string; + number_of_conformers?: number; + ensemble_sample_url?: string; + ensemble_sample_format?: string; + created: Date; + sequence_identity: number; + uniprot_start: number; + uniprot_end: number; + coverage: number; + experimental_method?: string; + resolution?: string; + confidence_type?: string; + confidence_version?: number; + confidence_avg_local_score: number; + oligomeric_state?: string; + preferred_assembly_id?: string; entities: { - entity_type: string, - entity_poly_type: string, - identifier: string, - identifier_category: string, - description: string, - chain_ids: string[] - }[] - } - }[] + entity_type: string; + entity_poly_type: string; + identifier: string; + identifier_category: string; + description: string; + chain_ids: string[]; + }[]; + }; + }[]; } export enum Source { - ALPHA_FOLD = "AlphaFold", - PDB = "PDB" + ALPHA_FOLD = 'AlphaFold', + PDB = 'PDB', } // Global variable avoid typescript errors @@ -106,28 +105,28 @@ declare const PDBeMolstarPlugin: any; MatOptgroup, MatOption, SafePipe, - MatProgressSpinner + MatProgressSpinner, ], styleUrl: './structure-viewer.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush - + changeDetection: ChangeDetectionStrategy.OnPush, }) export class StructureViewerComponent { - readonly obj = input.required(); readonly xRefs = input.required(); readonly moleculeType = input.required(); viewer = viewChild>('viewer'); isProtein = computed(() => this.moleculeType() === MoleculeType.PROTEIN); - isChemical = computed(() => this.moleculeType() === MoleculeType.CHEMICAL); + isChemical = computed( + () => + this.moleculeType() === MoleculeType.CHEMICAL || + this.moleculeType() === MoleculeType.CHEMICAL_DRUG + ); chebiIdentifier = signal(undefined); pdbIdentifiers = computed(() => this.getPDBIdentifiers(this.xRefs())); - reactomeStyle: Style = new Style(document.body); - alphaFoldEntryId = linkedSignal(() => { if (!this.isProtein()) return null; const summary = this.alphafoldSummary.value(); @@ -138,87 +137,111 @@ export class StructureViewerComponent { return null; }); - selected = signal(null); sourceLabel = computed(() => { - return this.selected()?.startsWith("AF-") ? Source.ALPHA_FOLD : Source.PDB; - }) + return this.selected()?.startsWith('AF-') ? Source.ALPHA_FOLD : Source.PDB; + }); /** protein structure data from AlphaFold and PDB */ proteinStructureData = computed(() => { if (!this.isProtein()) return null; - const result = [] + const result = []; const afId = this.alphaFoldEntryId(); - if (afId) result.push({source: Source.ALPHA_FOLD, identifiers: [afId]}) + if (afId) result.push({ source: Source.ALPHA_FOLD, identifiers: [afId] }); const pdbIdentifiers = this.pdbIdentifiers(); - if (pdbIdentifiers.length > 0) result.push({source: Source.PDB, identifiers: pdbIdentifiers}) + if (pdbIdentifiers.length > 0) + result.push({ source: Source.PDB, identifiers: pdbIdentifiers }); return result; }); - chebiStructureSVGData = rxResource({ request: this.chebiIdentifier, - loader: ({request}) => { + loader: ({ request }) => { const id = request; if (!id) return EMPTY; - return this.http.get(`https://www.ebi.ac.uk/chebi/backend/api/public/compound/${id}/structure/`, {responseType: 'text'}).pipe( - catchError(err => EMPTY) - ) - } - }) + return this.http + .get( + `https://www.ebi.ac.uk/chebi/backend/api/public/compound/${id}/structure/`, + { responseType: 'text' } + ) + .pipe(catchError((err) => EMPTY)); + }, + }); isChebiLoading = computed(() => this.chebiStructureSVGData.isLoading()); isAlphafoldSummaryLoading = computed(() => this.alphafoldSummary.isLoading()); - bestPdbStructure = rxResource({ request: () => this.obj().identifier, - loader: ({request}) => { + loader: ({ request }) => { if (!this.isProtein()) return EMPTY; const id = request; - return this.http.get(`https://www.ebi.ac.uk/pdbe/api/mappings/best_structures/${id}/`).pipe( - map(response => { - const value = response[id]; - const ids = new Set(value.map(item => item.pdb_id.toUpperCase())); - return Array.from(ids) - }), - catchError(err => EMPTY) - ) - } - }) + return this.http + .get( + `https://www.ebi.ac.uk/pdbe/api/mappings/best_structures/${id}/` + ) + .pipe( + map((response) => { + const value = response[id]; + const ids = new Set(value.map((item) => item.pdb_id.toUpperCase())); + return Array.from(ids); + }), + catchError((err) => EMPTY) + ); + }, + }); alphafoldSummary = rxResource({ request: () => this.obj().identifier, - loader: ({request}) => { + loader: ({ request }) => { if (!this.isProtein()) return EMPTY; const id = request; - return this.http.get(`https://alphafold.ebi.ac.uk/api/uniprot/summary/${id}.json`) - } - }) + return this.http.get( + `https://alphafold.ebi.ac.uk/api/uniprot/summary/${id}.json` + ); + }, + }); - alphafoldUrl = computed(() => this.alphafoldSummary.value()?.structures?.[0]?.summary?.model_url || `https://alphafold.ebi.ac.uk/files/${this.alphaFoldEntryId()}-model_v6.cif`) + alphafoldUrl = computed( + () => + this.alphafoldSummary.value()?.structures?.[0]?.summary?.model_url || + `https://alphafold.ebi.ac.uk/files/${this.alphaFoldEntryId()}-model_v6.cif` + ); - hasAnyStructure = computed(() => this.chebiStructureSVGData.hasValue() || !!this.proteinStructureData()?.length); + hasAnyStructure = computed( + () => + this.chebiStructureSVGData.hasValue() || + !!this.proteinStructureData()?.length + ); bgColor = computed(() => { this.dark.isDark(); // Compute on dark update return extract(this.reactomeStyle.properties.global.surface); - }) + }); - constructor(private dark: DarkService, - private http: HttpClient, - private structure: StructureService) { + constructor( + private dark: DarkService, + private http: HttpClient, + private structure: StructureService + ) { effect(() => { - const [isProtein, isChemical] = [this.isProtein(), this.isChemical()] + const [isProtein, isChemical] = [this.isProtein(), this.isChemical()]; + if (isProtein) { this.getProteinStructure(); } else if (isChemical) { - this.chebiIdentifier.set(this.obj().identifier); + const identifier = + this.obj().databaseName === 'ChEBI' + ? this.obj().identifier + : (this.obj().crossReference as DatabaseIdentifier[]).find( + (c) => c.databaseName === 'ChEBI' + )?.identifier; + if (identifier) this.chebiIdentifier.set(identifier); } }); @@ -245,7 +268,6 @@ export class StructureViewerComponent { } getProteinStructure() { - const viewerRef = this.viewer(); // signal value if (!viewerRef?.nativeElement) return; @@ -262,7 +284,7 @@ export class StructureViewerComponent { const pdbOptions = { moleculeId: selected.toLowerCase(), - ...options + ...options, }; const alphaFoldOptions = { @@ -272,29 +294,37 @@ export class StructureViewerComponent { }, alphafoldView: true, ...options, - } + }; // If only alfaFold data is available, check if the structure is available if (this.alphaFoldEntryId()) { - fetch(this.alphafoldUrl(), {method: 'HEAD'}) - .then(e => !e.ok && this.alphaFoldEntryId.set(null)); + fetch(this.alphafoldUrl(), { method: 'HEAD' }).then( + (e) => !e.ok && this.alphaFoldEntryId.set(null) + ); } - const finalOptions = selected.startsWith('AF-') ? alphaFoldOptions : pdbOptions; - viewerInstance.render(viewerRef.nativeElement, finalOptions) + const finalOptions = selected.startsWith('AF-') + ? alphaFoldOptions + : pdbOptions; + viewerInstance.render(viewerRef.nativeElement, finalOptions); } getPDBIdentifiers(xRefs: DatabaseIdentifier[]) { - const bestStructure = new Map(this.bestPdbStructure.value()?.map((id, index) => [id, index])); + const bestStructure = new Map( + this.bestPdbStructure.value()?.map((id, index) => [id, index]) + ); return xRefs .filter((ref: DatabaseIdentifier) => ref.databaseName === Source.PDB) - .map(ref => ref.identifier) + .map((ref) => ref.identifier) .sort((a, b) => { - if (bestStructure) { - const aIndex = bestStructure.has(a) ? bestStructure.get(a)! : Number.MAX_SAFE_INTEGER; - const bIndex = bestStructure.has(b) ? bestStructure.get(b)! : Number.MAX_SAFE_INTEGER; + const aIndex = bestStructure.has(a) + ? bestStructure.get(a)! + : Number.MAX_SAFE_INTEGER; + const bIndex = bestStructure.has(b) + ? bestStructure.get(b)! + : Number.MAX_SAFE_INTEGER; if (aIndex !== bIndex) { return aIndex - bIndex; @@ -302,7 +332,7 @@ export class StructureViewerComponent { } // fallback method when no best structure available return this.sortByAlphabeticalOrder(a, b); - }) + }); } sortByAlphabeticalOrder(a: string, b: string) { @@ -313,12 +343,11 @@ export class StructureViewerComponent { if (aDigit && bDigit) { return a.localeCompare(b); } else if (aDigit) { - return -1;// a comes before b + return -1; // a comes before b } else if (bDigit) { - return 1;// b comes before a + return 1; // b comes before a } else { - return a.localeCompare(b);// For non-digit,sort normally + return a.localeCompare(b); // For non-digit,sort normally } } - } diff --git a/projects/pathway-browser/src/app/diagram/diagram.component.ts b/projects/pathway-browser/src/app/diagram/diagram.component.ts index 1322e64..a92ae2a 100644 --- a/projects/pathway-browser/src/app/diagram/diagram.component.ts +++ b/projects/pathway-browser/src/app/diagram/diagram.component.ts @@ -10,12 +10,21 @@ import { Output, signal, viewChild, - ViewChild + ViewChild, } from '@angular/core'; -import {DiagramService} from "../services/diagram.service"; -import {extract, ReactomeEvent, ReactomeEventTypes, Style} from "reactome-cytoscape-style"; -import cytoscape, {BoundingBoxWH, ElementsDefinition} from "cytoscape"; -import {InteractorService} from "../interactors/services/interactor.service"; +import { DiagramService } from '../services/diagram.service'; +import { + extract, + ReactomeEvent, + ReactomeEventTypes, + Style, +} from 'reactome-cytoscape-style'; +import cytoscape, { + BoundingBox12, + BoundingBoxWH, + ElementsDefinition, +} from 'cytoscape'; +import { InteractorService } from '../interactors/services/interactor.service'; import { catchError, delay, @@ -30,31 +39,34 @@ import { Subject, switchMap, take, - tap -} from "rxjs"; -import {UrlStateService} from "../services/url-state.service"; -import {UntilDestroy} from "@ngneat/until-destroy"; -import {AnalysisService} from "../services/analysis.service"; -import {Graph} from "../model/graph.model"; -import {average, isDefined, isPathwayWithDiagram, isReferenceEntityStId} from "../services/utils"; -import type {Analysis} from "../model/analysis.model"; -import {ActivatedRoute, Router} from "@angular/router"; -import {InteractorsComponent} from "../interactors/interactors.component"; -import {EventService} from "../services/event.service"; -import {Event as EventModel} from "../model/graph/event/event.model"; - - -import {DarkService} from "../services/dark.service"; -import {DownloadFormat, DownloadService} from "../services/download.service"; -import {DataStateService} from "../services/data-state.service"; -import {SchemaClasses} from "../constants/constants"; -import {Interactor} from "../interactors/model/interactor.model"; -import {Point, CdkDrag, CdkDragHandle} from "@angular/cdk/drag-drop"; -import {NgClass} from "@angular/common"; -import {MatSlider, MatSliderThumb} from "@angular/material/slider"; -import {MatTooltip} from "@angular/material/tooltip"; -import {AnalysisLegendComponent} from "../legend/analysis-legend/analysis-legend.component"; - + tap, +} from 'rxjs'; +import { UrlStateService } from '../services/url-state.service'; +import { UntilDestroy } from '@ngneat/until-destroy'; +import { AnalysisService } from '../services/analysis.service'; +import { Graph } from '../model/graph.model'; +import { + average, + isDefined, + isPathwayWithDiagram, + isReferenceEntityStId, +} from '../services/utils'; +import type { Analysis } from '../model/analysis.model'; +import { ActivatedRoute, Router } from '@angular/router'; +import { InteractorsComponent } from '../interactors/interactors.component'; +import { EventService } from '../services/event.service'; +import { Event as EventModel } from '../model/graph/event/event.model'; + +import { DarkService } from '../services/dark.service'; +import { DownloadFormat, DownloadService } from '../services/download.service'; +import { DataStateService } from '../services/data-state.service'; +import { SchemaClasses } from '../constants/constants'; +import { Interactor } from '../interactors/model/interactor.model'; +import { Point, CdkDrag, CdkDragHandle } from '@angular/cdk/drag-drop'; +import { NgClass } from '@angular/common'; +import { MatSlider, MatSliderThumb } from '@angular/material/slider'; +import { MatTooltip } from '@angular/material/tooltip'; +import { AnalysisLegendComponent } from '../legend/analysis-legend/analysis-legend.component'; const INIT_RX = 2; @@ -62,7 +74,7 @@ const END_RX = 0; const FIT_PADDING = 100; -@UntilDestroy({checkProperties: true}) +@UntilDestroy({ checkProperties: true }) @Component({ selector: 'cr-diagram', templateUrl: './diagram.component.html', @@ -74,148 +86,267 @@ const FIT_PADDING = 100; MatSlider, MatSliderThumb, MatTooltip, - AnalysisLegendComponent - ] + AnalysisLegendComponent, + ], }) export class DiagramComponent implements AfterViewInit, OnDestroy { title = 'pathway-browser'; @ViewChild('cytoscape') cytoscapeContainer?: ElementRef; @ViewChild('cytoscapeCompare') compareContainer?: ElementRef; @ViewChild('legend') legendContainer?: ElementRef; - readonly thumbnailRef = viewChild.required>('thumbnail'); + readonly thumbnailRef = + viewChild.required>('thumbnail'); - readonly interactorsComponent = input(undefined, {alias: "interactor"}); + readonly interactorsComponent = input(undefined, { + alias: 'interactor', + }); readonly pathwayId = model.required(); - readonly controlZoom = signal(0); readonly controlMinZoom = signal(1); readonly controlMaxZoom = signal(100); - readonly controlRange = computed(() => this.controlMaxZoom() - this.controlMinZoom()); + readonly controlRange = computed( + () => this.controlMaxZoom() - this.controlMinZoom() + ); comparing: boolean = false; isInitialLoad: boolean = true; - constructor(private diagram: DiagramService, - public dark: DarkService, - private interactorsService: InteractorService, - private state: UrlStateService, - public analysis: AnalysisService, - private event: EventService, - private router: Router, - private route: ActivatedRoute, - private download: DownloadService, - private data: DataStateService + constructor( + private diagram: DiagramService, + public dark: DarkService, + private interactorsService: InteractorService, + private state: UrlStateService, + public analysis: AnalysisService, + private event: EventService, + private router: Router, + private route: ActivatedRoute, + private download: DownloadService, + private data: DataStateService ) { - this.isInitialLoad = Boolean(!this.router.getCurrentNavigation()?.previousNavigation); + this.isInitialLoad = Boolean( + !this.router.getCurrentNavigation()?.previousNavigation + ); effect(() => this.pathwayId() && this.loadDiagram()); - effect(() => { - const flag = this.data.flagIdentifiers(); - if (!this.data.flagResource.isLoading()) this.avoidSideEffect(() => this.cys.forEach(cy => this.flag(this.data.flagIdentifiers(), cy))) - // this.flagging = false; - }, {debugName: 'diagram flagging'}); - effect(() => { - if (this.state.select() && !this.selecting) this.avoidSideEffect(() => this.cys.forEach(cy => this.select(this.state.select()!, cy))) - this.selecting = false; - }, {debugName: 'diagram selecting'}); + effect( + () => { + const flag = this.data.flagIdentifiers(); + if (!this.data.flagResource.isLoading()) + this.avoidSideEffect(() => + this.cys.forEach((cy) => this.flag(this.data.flagIdentifiers(), cy)) + ); + // this.flagging = false; + }, + { debugName: 'diagram flagging' } + ); + effect( + () => { + if (this.state.select() && !this.selecting) + this.avoidSideEffect(() => + this.cys.forEach((cy) => this.select(this.state.select()!, cy)) + ); + this.selecting = false; + }, + { debugName: 'diagram selecting' } + ); effect(() => { const result = this.state.analysis(); // Not in one line to make sure to trigger the update - this.avoidSideEffect(() => this.loadAnalysis(result)) + this.avoidSideEffect(() => this.loadAnalysis(result)); }); - effect(() => this.analysis.palette() && this.reactomeStyle?.loadAnalysis(this.cy, this.analysis.palette().scale)); - effect(() => - this.analysis.sampleIndex() !== undefined && - this._loadAnalysisFn && - this._loadAnalysisFn(this.analysis.sampleIndex()) + effect( + () => + this.analysis.palette() && + this.reactomeStyle?.loadAnalysis(this.cy, this.analysis.palette().scale) + ); + effect( + () => + this.analysis.sampleIndex() !== undefined && + this._loadAnalysisFn && + this._loadAnalysisFn(this.analysis.sampleIndex()) ); - effect(() => { // Update style upon dark change + effect(() => { + // Update style upon dark change this.dark.isDark(); this.updateStyle(); - }) + }); - effect(() => { + effect(async () => { const request = this.download.downloadRequest(); if (request) { this.export(request.format); this.download.resetDownload(); } }); - } - export(format: string) { - const options: cytoscape.ExportOptions = { + async export(format: string) { + const options: cytoscape.ExportJpgBlobPromiseOptions = { full: true, - ...(format === DownloadFormat.JPEG ? {quality: 0.9} : {}) + ...(format === DownloadFormat.JPEG ? { quality: 0.9 } : {}), + bg: 'transparent', + output: 'blob-promise', + }; + + const blobs = this.cys.map((cy) => + format === DownloadFormat.PNG ? cy.png(options) : cy.jpg(options) + ); + let blob: Blob; + if (blobs.length > 1) { + const images = await Promise.all( + blobs.map((blob) => blob.then(createImageBitmap)) + ); + const bbs = this.cys.map((cy) => + cy.elements().boundingBox({ includeLabels: false }) + ); + const bgColors = this.cys.map( + (cy) => getComputedStyle(cy.container()!).backgroundColor + ); + blob = await this.mergeImages( + images, + bbs, + bgColors, + format === DownloadFormat.JPEG ? 'image/jpeg' : 'image/png', + options?.quality + ); + } else { + blob = await blobs[0]; } + const a = document.createElement('a'); - a.href = format === DownloadFormat.PNG ? this.cy.png(options) : this.cy.jpg(options); + a.href = URL.createObjectURL(blob); a.download = `${this.pathwayId()}.${format}`; a.click(); a.remove(); } - zoomToCytoscapeTransform = (x: number) => this.minZoom() * Math.pow(this.maxZoom() / this.minZoom(), (x - this.controlMinZoom()) / this.controlRange()); - zoomToControlTransform = (zoomCy: number) => this.controlMinZoom() + this.controlRange() * (Math.log(zoomCy / this.minZoom()) / Math.log(this.maxZoom() / this.minZoom())); + async mergeImages( + images: ImageBitmap[], + bbs: BoundingBox12[], + bgColors: string[], + format: 'image/jpeg' | 'image/png', + quality?: number + ): Promise { + // Compute merged canvas size in model space + const xMin = Math.min(...bbs.map((bb) => bb.x1)); + const yMin = Math.min(...bbs.map((bb) => bb.y1)); + const xMax = Math.max(...bbs.map((bb) => bb.x2)); + const yMax = Math.max(...bbs.map((bb) => bb.y2)); + + // Calculate scale ratio between pixel space (image) and model space (bounding box) + // Assume all images have the same scale - use the first one + const bbWidth = bbs[0].x2 - bbs[0].x1; + const scale = images[0].width / bbWidth; + + const mergedWidth = (xMax - xMin) * scale; + const mergedHeight = (yMax - yMin) * scale; + + const canvas = document.createElement('canvas'); + canvas.width = mergedWidth; + canvas.height = mergedHeight; + const ctx = canvas.getContext('2d')!; + if (format === 'image/jpeg') { + ctx.fillStyle = this.dark.isDark() ? '#000' : '#fff'; + ctx.fillRect(0, 0, mergedWidth, mergedHeight); + } + + images.forEach((image, i) => { + const offsetX = (bbs[i].x1 - xMin) * scale; // shift relative to merged bbox, scaled to pixel space + const offsetY = (bbs[i].y1 - yMin) * scale; + + // Fill background color for this layer + ctx.fillStyle = bgColors[i]; + ctx.fillRect(0, 0, mergedWidth, mergedHeight); + + // Draw image on top + ctx.drawImage(image, offsetX, offsetY); + image.close(); + }); + + return new Promise((resolve) => + canvas.toBlob((blob) => resolve(blob!), format, quality) + ); + } + + zoomToCytoscapeTransform = (x: number) => + this.minZoom() * + Math.pow( + this.maxZoom() / this.minZoom(), + (x - this.controlMinZoom()) / this.controlRange() + ); + zoomToControlTransform = (zoomCy: number) => + this.controlMinZoom() + + this.controlRange() * + (Math.log(zoomCy / this.minZoom()) / + Math.log(this.maxZoom() / this.minZoom())); thumbnailImg = signal(''); sizeObserver!: ResizeObserver; - containerSize = signal<{ width: number, height: number }>({width: 0, height: 0}); - thumbnailSize = signal<{ width: number, height: number }>({width: 0, height: 0}); - boundingBox = signal({x1: 0, y1: 1, w: 1, h: 1}); - + containerSize = signal<{ width: number; height: number }>({ + width: 0, + height: 0, + }); + thumbnailSize = signal<{ width: number; height: number }>({ + width: 0, + height: 0, + }); + boundingBox = signal({ x1: 0, y1: 1, w: 1, h: 1 }); - thumbnailViewBox = computed(() => `0 0 ${this.thumbnailSize().width} ${this.thumbnailSize().height}`) - viewportPosition = signal<{ x: number, y: number }>({x: 0, y: 0}); + thumbnailViewBox = computed( + () => `0 0 ${this.thumbnailSize().width} ${this.thumbnailSize().height}` + ); + viewportPosition = signal<{ x: number; y: number }>({ x: 0, y: 0 }); zoomLevel = signal(0.1); minZoom = signal(0.1); maxZoom = signal(15); - thumbnailRxA = computed(() => (END_RX - INIT_RX) / (this.maxZoom() - this.minZoom())); + thumbnailRxA = computed( + () => (END_RX - INIT_RX) / (this.maxZoom() - this.minZoom()) + ); thumbnailRxB = computed(() => INIT_RX - this.thumbnailRxA() * this.minZoom()); - thumbnailRx = computed(() => this.zoomLevel() * this.thumbnailRxA() + this.thumbnailRxB()); - + thumbnailRx = computed( + () => this.zoomLevel() * this.thumbnailRxA() + this.thumbnailRxB() + ); shrunkViewport = computed(() => { -// Get bounding box of the entire graph + // Get bounding box of the entire graph const bbox = this.boundingBox(); -// Get current zoom and pan + // Get current zoom and pan const zoom = this.zoomLevel(); const pan = this.viewportPosition(); // {x, y} -// Get main container size (in pixels) + // Get main container size (in pixels) const mainWidth = this.containerSize().width; const mainHeight = this.containerSize().height; -// Define your thumbnail size (in pixels) + // Define your thumbnail size (in pixels) const thumbWidth = this.thumbnailSize().width; const thumbHeight = this.thumbnailSize().height; -// Compute scale factor between global graph and thumbnail + // Compute scale factor between global graph and thumbnail const scaleX = thumbWidth / bbox.w; const scaleY = thumbHeight / bbox.h; const scale = Math.min(scaleX, scaleY); // uniform scaling -// Offset to center the graph in the thumbnail + // Offset to center the graph in the thumbnail const offsetX = (thumbWidth - bbox.w * scale) / 2; const offsetY = (thumbHeight - bbox.h * scale) / 2; -// Viewport dimensions in graph coordinate space + // Viewport dimensions in graph coordinate space const viewW = mainWidth / zoom; const viewH = mainHeight / zoom; -// Viewport top-left in graph space + // Viewport top-left in graph space const viewX = -pan.x / zoom; const viewY = -pan.y / zoom; -// Convert to thumbnail coordinates + // Convert to thumbnail coordinates return { x: (viewX - bbox.x1) * scale + offsetX, y: (viewY - bbox.y1) * scale + offsetY, width: viewW * scale, - height: viewH * scale - } + height: viewH * scale, + }; }); cy!: cytoscape.Core; @@ -224,14 +355,13 @@ export class DiagramComponent implements AfterViewInit, OnDestroy { reactomeStyleCompare!: Style; legend!: cytoscape.Core; cys: cytoscape.Core[] = []; - reactomeStyles: Style[] = [] + reactomeStyles: Style[] = []; leafIdToParentIds = new Map(); - hovering = signal(false) - selecting = false // Avoid zooming in diagram when selection came from in diagram - flagging = false // Avoid flagging in diagram when flagging came from in diagram - + hovering = signal(false); + selecting = false; // Avoid zooming in diagram when selection came from in diagram + flagging = false; // Avoid flagging in diagram when flagging came from in diagram ngAfterViewInit(): void { const container = this.cytoscapeContainer!.nativeElement; @@ -239,33 +369,40 @@ export class DiagramComponent implements AfterViewInit, OnDestroy { const legendContainer = this.legendContainer!.nativeElement; Object.values(ReactomeEventTypes).forEach((type) => { - container.addEventListener(type, (e) => this._reactomeEvents$.next(e as ReactomeEvent)) - compareContainer.addEventListener(type, (e) => this._reactomeEvents$.next(e as ReactomeEvent)) - legendContainer.addEventListener(type, (e) => this._reactomeEvents$.next(e as ReactomeEvent)) - }) + container.addEventListener(type, (e) => + this._reactomeEvents$.next(e as ReactomeEvent) + ); + compareContainer.addEventListener(type, (e) => + this._reactomeEvents$.next(e as ReactomeEvent) + ); + legendContainer.addEventListener(type, (e) => + this._reactomeEvents$.next(e as ReactomeEvent) + ); + }); this.reactomeStyle = new Style(container); - this.underlayPadding = extract(this.reactomeStyle.properties.shadow.padding) - - this.diagram.getLegend() - .subscribe(legend => { - this.legend = cytoscape({ - container: legendContainer, - elements: legend, - style: this.reactomeStyle?.getStyleSheet(), - layout: {name: "preset"}, - boxSelectionEnabled: false - }); - this.reactomeStyle?.bindToCytoscape(this.legend); + this.underlayPadding = extract( + this.reactomeStyle.properties.shadow.padding + ); - this.legend.zoomingEnabled(false); - this.legend.panningEnabled(false); - this.legend.minZoom(0) + this.diagram.getLegend().subscribe((legend) => { + this.legend = cytoscape({ + container: legendContainer, + elements: legend, + style: this.reactomeStyle?.getStyleSheet(), + layout: { name: 'preset' }, + boxSelectionEnabled: false, }); + this.reactomeStyle?.bindToCytoscape(this.legend); + + this.legend.zoomingEnabled(false); + this.legend.panningEnabled(false); + this.legend.minZoom(0); + }); - this.sizeObserver = new ResizeObserver(entries => { - entries.forEach(entry => { + this.sizeObserver = new ResizeObserver((entries) => { + entries.forEach((entry) => { if (entry.target === container) { this.containerSize.set(entry.contentRect); @@ -274,23 +411,24 @@ export class DiagramComponent implements AfterViewInit, OnDestroy { const bbox = this.boundingBox(); const minZoom = Math.min( this.containerSize().width / (bbox.w + FIT_PADDING), - this.containerSize().height / (bbox.h + FIT_PADDING), + this.containerSize().height / (bbox.h + FIT_PADDING) ); this.minZoom.set(minZoom); - this.cys.forEach(cy => { + this.cys.forEach((cy) => { cy.minZoom(minZoom); if (cy.zoom() < minZoom) { this.zoomLevel.set(minZoom); cy.zoom(minZoom); } - }) + }); } } - if (entry.target === this.thumbnailRef().nativeElement) this.thumbnailSize.set(entry.contentRect) - }) - }) + if (entry.target === this.thumbnailRef().nativeElement) + this.thumbnailSize.set(entry.contentRect); + }); + }); this.sizeObserver.observe(container); this.sizeObserver.observe(this.thumbnailRef().nativeElement); @@ -299,7 +437,9 @@ export class DiagramComponent implements AfterViewInit, OnDestroy { } thumbnailLoaded() { - this.thumbnailSize.set(this.thumbnailRef().nativeElement.getBoundingClientRect()) + this.thumbnailSize.set( + this.thumbnailRef().nativeElement.getBoundingClientRect() + ); } ngOnDestroy(): void { @@ -309,133 +449,162 @@ export class DiagramComponent implements AfterViewInit, OnDestroy { // Needs Input event binding to react to mouse drag instead of mouse drop on slider zoom(inputEvent: Event) { this.cy.zoom({ - level: this.zoomToCytoscapeTransform((inputEvent.target as HTMLInputElement).valueAsNumber), - renderedPosition: {x: this.cy.width() / 2, y: this.cy.height() / 2} - }) + level: this.zoomToCytoscapeTransform( + (inputEvent.target as HTMLInputElement).valueAsNumber + ), + renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 }, + }); } zoomIn() { this.cy.zoom({ level: this.cy.zoom() * 1.2, - renderedPosition: {x: this.cy.width() / 2, y: this.cy.height() / 2} - }) + renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 }, + }); } zoomOut() { this.cy.zoom({ level: this.cy.zoom() / 1.2, - renderedPosition: {x: this.cy.width() / 2, y: this.cy.height() / 2} - }) + renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 }, + }); } move(direction: 'up' | 'right' | 'down' | 'left', distance = 50) { - const x = direction === 'right' ? -distance : direction === 'left' ? distance : 0; - const y = direction === 'up' ? distance : direction === 'down' ? -distance : 0; - this.cy.panBy({x, y}) + const x = + direction === 'right' ? -distance : direction === 'left' ? distance : 0; + const y = + direction === 'up' ? distance : direction === 'down' ? -distance : 0; + this.cy.panBy({ x, y }); } fitScreen() { this.cy.animate({ fit: { - eles: "*", - padding: FIT_PADDING + eles: '*', + padding: FIT_PADDING, }, duration: 1000, - easing: "ease-in-out" - }) + easing: 'ease-in-out', + }); } private loadDiagram(): void { - this.event.diagramPathway$.pipe( - filter(isDefined), - take(1), - switchMap((event) => { - // If the diagramId is a subpathway without diagram, and it is a first load then load parent diagram - // For instance: ../PathwayBrowser/R-HSA-69541 - if (!isPathwayWithDiagram(event) && this.isInitialLoad) { - return this.loadSubpathwayWithDiagram(event); - } - // Pathway with a diagram - return this.loadElvDiagram(); - }), - catchError(() => of(null)) - ).subscribe(() => { - this.isInitialLoad = false; - }); + this.event.diagramPathway$ + .pipe( + filter(isDefined), + take(1), + switchMap((event) => { + // If the diagramId is a subpathway without diagram, and it is a first load then load parent diagram + // For instance: ../PathwayBrowser/R-HSA-69541 + if (!isPathwayWithDiagram(event) && this.isInitialLoad) { + return this.loadSubpathwayWithDiagram(event); + } + // Pathway with a diagram + return this.loadElvDiagram(); + }), + catchError(() => of(null)) + ) + .subscribe(() => { + this.isInitialLoad = false; + }); } - loadElvDiagram(): Observable { if (!this.cytoscapeContainer) return EMPTY; // Prevent execution if the container is not present const container = this.cytoscapeContainer.nativeElement; return this.diagram.getDiagram(this.pathwayId()!).pipe( - tap(elements => { - this.comparing = elements.nodes.some(node => node.data['isFadeOut']) || - elements.edges.some(edge => edge.data['isFadeOut']); + tap((elements) => { + this.comparing = + elements.nodes.some((node) => node.data['isFadeOut']) || + elements.edges.some((edge) => edge.data['isFadeOut']); this.cy = cytoscape({ container: container, elements: elements, style: this.reactomeStyle?.getStyleSheet(), - layout: {name: "preset"}, + layout: { name: 'preset' }, }); this.cys[0] = this.cy; this.reactomeStyles[0] = this.reactomeStyle; this.reactomeStyle.bindToCytoscape(this.cy); this.leafIdToParentIds.clear(); - this.cy.nodes().forEach(node => { + this.cy.nodes().forEach((node) => { node.data('graph.leaves')?.forEach((leaf: Graph.Node) => { - if (!this.leafIdToParentIds.has(leaf.stId)) this.leafIdToParentIds.set(leaf.stId, []) - if (leaf.standardIdentifier && !this.leafIdToParentIds.has(leaf.standardIdentifier)) this.leafIdToParentIds.set(leaf.standardIdentifier, this.leafIdToParentIds.get(leaf.stId)!) + if (!this.leafIdToParentIds.has(leaf.stId)) + this.leafIdToParentIds.set(leaf.stId, []); + if ( + leaf.standardIdentifier && + !this.leafIdToParentIds.has(leaf.standardIdentifier) + ) + this.leafIdToParentIds.set( + leaf.standardIdentifier, + this.leafIdToParentIds.get(leaf.stId)! + ); let parents = this.leafIdToParentIds.get(leaf.stId)!; parents.push(node.data('graph.stId')); - }) - }) + }); + }); - this.cy.on('zoom', () => this.controlZoom.set(this.zoomToControlTransform(this.cy.zoom()))); + this.cy.on('zoom', () => + this.controlZoom.set(this.zoomToControlTransform(this.cy.zoom())) + ); this.reactomeStyle.clearCache(); - this.cy.on('dblclick', '.SUB.Pathway', (e) => this.state.navigateTo(e.target.data('graph.stId'), { - queryParamsHandling: "preserve", - preserveFragment: true - })) + this.cy.on('dblclick', '.SUB.Pathway', (e) => + this.state.navigateTo(e.target.data('graph.stId'), { + queryParamsHandling: 'preserve', + preserveFragment: true, + }) + ); - this.cy.on('dblclick', '.Interacting.Pathway', (e) => this.state.navigateTo(e.target.data('graph.stId'), { - queryParams: {select: this.pathwayId()}, - queryParamsHandling: "merge", - preserveFragment: true - })) + this.cy.on('dblclick', '.Interacting.Pathway', (e) => + this.state.navigateTo(e.target.data('graph.stId'), { + queryParams: { select: this.pathwayId() }, + queryParamsHandling: 'merge', + preserveFragment: true, + }) + ); const shadowNodes = this.cy?.nodes('.Shadow'); - this.event.setSubpathwayColors(shadowNodes && shadowNodes.length > 0 - ? new Map(shadowNodes.map(node => [node.data('reactomeId'), node.data('color')])) - : undefined); + this.event.setSubpathwayColors( + shadowNodes && shadowNodes.length > 0 + ? new Map( + shadowNodes.map((node) => [ + node.data('reactomeId'), + node.data('color'), + ]) + ) + : undefined + ); setTimeout(() => { - this.thumbnailImg.set(this.cy.png({full: true, maxHeight: 240})) - }, 5) + this.thumbnailImg.set(this.cy.png({ full: true, maxHeight: 240 })); + }, 5); this.cy.on('viewport', () => { this.zoomLevel.set(this.cy.zoom()); - this.viewportPosition.set({...this.cy.pan()}); - }) + this.viewportPosition.set({ ...this.cy.pan() }); + }); this.zoomLevel.set(this.cy.zoom()); this.minZoom.set(this.cy.minZoom()); this.maxZoom.set(this.cy.maxZoom()); - this.viewportPosition.set({...this.cy.pan()}); - this.boundingBox.set(this.cy.elements().boundingBox({ - includeEdges: true, - includeNodes: true, - - includeLabels: false, - includeMainLabels: false, - includeOverlays: false, - includeUnderlays: false, - includeSourceLabels: false, - includeTargetLabels: false, - })); + this.viewportPosition.set({ ...this.cy.pan() }); + this.boundingBox.set( + this.cy.elements().boundingBox({ + includeEdges: true, + includeNodes: true, + + includeLabels: false, + includeMainLabels: false, + includeOverlays: false, + includeUnderlays: false, + includeSourceLabels: false, + includeTargetLabels: false, + }) + ); this.loadCompare(elements, container); @@ -446,7 +615,7 @@ export class DiagramComponent implements AfterViewInit, OnDestroy { loadSubpathwayWithDiagram(event: EventModel) { return this.event.fetchEventAncestors(this.pathwayId()!).pipe( - map(ancestors => this.event.getFinalAncestor(ancestors)), + map((ancestors) => this.event.getFinalAncestor(ancestors)), switchMap((ancestors) => { const pathwayWithDiagram = this.event.getPathwayWithDiagram(event); if (pathwayWithDiagram) { @@ -463,7 +632,7 @@ export class DiagramComponent implements AfterViewInit, OnDestroy { return this.loadElvDiagram(); } } - return of(null) + return of(null); }) ); } @@ -471,21 +640,23 @@ export class DiagramComponent implements AfterViewInit, OnDestroy { public initialiseReplaceElements() { if (this.comparing) this.cy.batch(() => { - this.cy.elements('[!isBackground]').style('visibility', 'hidden') - this.cy.edges('.shadow').style('underlay-padding', 0) + this.cy.elements('[!isBackground]').style('visibility', 'hidden'); + this.cy.edges('.shadow').style('underlay-padding', 0); this.lastIndex = 0; this.updateReplacementVisibility(); - this.cy.elements('.Compartment').style('visibility', 'visible') - }) + this.cy.elements('.Compartment').style('visibility', 'visible'); + }); } - private loadCompare(elements: cytoscape.ElementsDefinition, container: HTMLDivElement) { - - const getPosition = (e: cytoscape.SingularElementArgument) => e.is('.Shadow') ? e.data('triggerPosition') : e.boundingBox().x1; + private loadCompare( + elements: cytoscape.ElementsDefinition, + container: HTMLDivElement + ) { + const getPosition = (e: cytoscape.SingularElementArgument) => + e.is('.Shadow') ? e.data('triggerPosition') : e.boundingBox().x1; if (this.comparing) { - this.cy.elements('[!isBackground]').style('visibility', 'hidden') - this.replacedElements = this.cy! - .elements('[?replacedBy]') + this.cy.elements('[!isBackground]').style('visibility', 'hidden'); + this.replacedElements = this.cy!.elements('[?replacedBy]') .add('[?isCrossed]') .sort((a, b) => getPosition(a) - getPosition(b)) .style('visibility', 'hidden') @@ -493,149 +664,200 @@ export class DiagramComponent implements AfterViewInit, OnDestroy { this.replacedElementsPosition = this.replacedElements.map(getPosition); - - this.cy.on('add', e => { + this.cy.on('add', (e) => { const addedElement = e.target; if (addedElement.data('replacedBy') || addedElement.data('isCrossed')) { const x = getPosition(addedElement); - let index = this.replacedElementsPosition.findIndex(x1 => x1 >= x); + let index = this.replacedElementsPosition.findIndex((x1) => x1 >= x); if (index === -1) index = this.replacedElements.length; this.replacedElements.splice(index, 0, addedElement); this.replacedElementsPosition.splice(index, 0, x); addedElement.style('visibility', 'hidden'); } - }) + }); - this.cy.on('remove', e => { + this.cy.on('remove', (e) => { const removedElement = e.target; const index = this.replacedElements.indexOf(removedElement); if (index > -1) { this.replacedElements.splice(index, 1); this.replacedElementsPosition.splice(index, 1); } - }) + }); const compareContainer = this.compareContainer!.nativeElement; this.cyCompare = cytoscape({ container: compareContainer, elements: elements, style: this.reactomeStyle?.getStyleSheet(), - layout: {name: "preset"}, + layout: { name: 'preset' }, }); - this.cyCompare.elements('[?isFadeOut]').remove(); this.cyCompare.elements('.Compartment').remove(); this.cy!.nodes('.crossed').removeClass('crossed'); - this.cyCompare!.on('viewport', () => this.syncViewports(this.cyCompare, compareContainer, this.cy, container)) - this.cy!.on('viewport', () => this.syncViewports(this.cy, container, this.cyCompare, compareContainer)) + this.cyCompare!.on('viewport', () => + this.syncViewports(this.cyCompare, compareContainer, this.cy, container) + ); + this.cy!.on('viewport', () => + this.syncViewports(this.cy, container, this.cyCompare, compareContainer) + ); this.reactomeStyleCompare = new Style(compareContainer); this.reactomeStyleCompare?.bindToCytoscape(this.cyCompare); - this.cyCompare.minZoom(this.cy!.minZoom()) - this.cyCompare.maxZoom(this.cy!.maxZoom()) + this.cyCompare.minZoom(this.cy!.minZoom()); + this.cyCompare.maxZoom(this.cy!.maxZoom()); this.cys[1] = this.cyCompare; this.reactomeStyles[1] = this.reactomeStyleCompare; setTimeout(() => { - this.syncViewports(this.cy!, container, this.cyCompare, compareContainer) + this.syncViewports( + this.cy!, + container, + this.cyCompare, + compareContainer + ); this.initialiseReplaceElements(); - }) + }); } } - readonly classRegex = /class:(\w+)([!.]drug)?/ + readonly classRegex = /class:(\w+)([!.]drug)?/; - getElements(tokens: (string | number)[], cy: cytoscape.Core, includeContainers = false): cytoscape.CollectionArgument { + getElements( + tokens: (string | number)[], + cy: cytoscape.Core, + includeContainers = false + ): cytoscape.CollectionArgument { let elements: cytoscape.Collection; - elements = cy.collection() - tokens.forEach(token => { + elements = cy.collection(); + tokens.forEach((token) => { if (typeof token === 'string') { if (token.startsWith('R-')) { let tokenElements = cy.collection(`[graph.stId="${token}"]`); // Load children - if ((includeContainers || tokenElements.length === 0) && this.leafIdToParentIds.has(token)) this.leafIdToParentIds.get(token)!.forEach(parent => tokenElements = tokenElements.or(`[graph.stId="${parent}"]`)) + if ( + (includeContainers || tokenElements.length === 0) && + this.leafIdToParentIds.has(token) + ) + this.leafIdToParentIds + .get(token)! + .forEach( + (parent) => + (tokenElements = tokenElements.or(`[graph.stId="${parent}"]`)) + ); elements = elements.or(tokenElements); // Consider it as a subpathway when there are no elements found and get all reactions if (elements.length === 0) { let allSubpathwaysElements = elements.or('[subpathways]'); - allSubpathwaysElements.forEach(ele => { + allSubpathwaysElements.forEach((ele) => { let pathwayList = ele.data('subpathways'); if (pathwayList.includes(token)) { elements.merge(ele); } }); } - } else if (token.includes(":") && !token.startsWith('class')) { // ReferenceEntity stId + } else if (token.includes(':') && !token.startsWith('class')) { + // ReferenceEntity stId elements = elements.or(`[graph.standardIdentifier="${token}"]`); - if ((includeContainers || elements.length === 0) && this.leafIdToParentIds.has(token)) this.leafIdToParentIds.get(token)!.forEach(parent => elements = elements.or(`[graph.stId="${parent}"]`)) - } else { // work with class ➡️ [class:Molecule!drug] + if ( + (includeContainers || elements.length === 0) && + this.leafIdToParentIds.has(token) + ) + this.leafIdToParentIds + .get(token)! + .forEach( + (parent) => (elements = elements.or(`[graph.stId="${parent}"]`)) + ); + } else { + // work with class ➡️ [class:Molecule!drug] const matchArray = token.match(this.classRegex); if (matchArray) { const [_, clazz, drug] = matchArray; - if (drug === '.drug') { // Drug physical entity + if (drug === '.drug') { + // Drug physical entity elements = elements.or(`.${clazz}`).and('.drug'); - } else if (drug === '!drug') { // Non drug physical entity + } else if (drug === '!drug') { + // Non drug physical entity elements = elements.or(`.${clazz}`).not('.drug'); - } else { // Reactions + } else { + // Reactions elements = elements.or(`.${clazz}`); - elements = elements.or(elements.nodes('.reaction').connectedEdges()); + elements = elements.or( + elements.nodes('.reaction').connectedEdges() + ); } } else { - elements = elements.or(`[acc="${token}"]`) + elements = elements.or(`[acc="${token}"]`); } } } else { - elements = elements.or(`[acc="${token}"]`).or(`[reactomeId="${token}"]`) + elements = elements + .or(`[acc="${token}"]`) + .or(`[reactomeId="${token}"]`); } }); return elements; } - select(tokens: (string | number), cy: cytoscape.Core): cytoscape.CollectionArgument { + select( + tokens: string | number, + cy: cytoscape.Core + ): cytoscape.CollectionArgument { cy.elements(':selected').unselect(); - const includeContainers = typeof tokens === 'string' && isReferenceEntityStId(tokens); + const includeContainers = + typeof tokens === 'string' && isReferenceEntityStId(tokens); let selected = this.getElements([tokens], cy, includeContainers); selected.select(); - if ("connectedNodes" in selected) { + if ('connectedNodes' in selected) { selected = selected.add(selected.connectedNodes()); } if (cy === this.cy) { - let running = true; - this.cy.animate({ - fit: {eles: selected, padding: 100}, - duration: 1000, - easing: 'ease-in-out', - }, { - complete: () => { - running = false; + this.cy.animate( + { + fit: { eles: selected, padding: 100 }, + duration: 1000, + easing: 'ease-in-out', + }, + { + complete: () => { + running = false; + }, } - }); + ); if (this.cyCompare) { const syncFrame = () => { if (!running) return; - this.syncViewports(this.cy, this.cytoscapeContainer!.nativeElement, this.cyCompare, this.compareContainer!.nativeElement, true) + this.syncViewports( + this.cy, + this.cytoscapeContainer!.nativeElement, + this.cyCompare, + this.compareContainer!.nativeElement, + true + ); requestAnimationFrame(syncFrame); - } + }; requestAnimationFrame(syncFrame); } - } - return selected; } - getFittedViewport(cy: cytoscape.Core, eles: cytoscape.CollectionArgument, padding = 100) { + getFittedViewport( + cy: cytoscape.Core, + eles: cytoscape.CollectionArgument, + padding = 100 + ) { // Save original state const origPan = cy.pan(); const origZoom = cy.zoom(); @@ -647,7 +869,7 @@ export class DiagramComponent implements AfterViewInit, OnDestroy { cy.fit(eles, padding); // Read target values - targetPan = {...cy.pan()}; + targetPan = { ...cy.pan() }; targetZoom = cy.zoom(); // Restore original state @@ -655,33 +877,41 @@ export class DiagramComponent implements AfterViewInit, OnDestroy { cy.zoom(origZoom); }); - console.log(targetPan, targetZoom, origPan, origZoom) + console.log(targetPan, targetZoom, origPan, origZoom); - return {pan: {...targetPan! as cytoscape.Position}, zoom: targetZoom! as number}; + return { + pan: { ...(targetPan! as cytoscape.Position) }, + zoom: targetZoom! as number, + }; } - flag(accs: (string | number)[], cy: cytoscape.Core): cytoscape.CollectionArgument { - return this.flagElements(this.getElements(accs, cy, true), cy) + flag( + accs: (string | number)[], + cy: cytoscape.Core + ): cytoscape.CollectionArgument { + return this.flagElements(this.getElements(accs, cy, true), cy); } - flagElements(toFlag: cytoscape.CollectionArgument, cy: cytoscape.Core): cytoscape.CollectionArgument { + flagElements( + toFlag: cytoscape.CollectionArgument, + cy: cytoscape.Core + ): cytoscape.CollectionArgument { if (toFlag.nonempty()) { cy.batch(() => { this.setSubPathwayVisibility(false, cy); - cy.elements().removeClass('flag') - toFlag.addClass('flag') - .edges().style({'underlay-opacity': 1}) - }) + cy.elements().removeClass('flag'); + toFlag.addClass('flag').edges().style({ 'underlay-opacity': 1 }); + }); - return toFlag + return toFlag; } else { cy.batch(() => { this.setSubPathwayVisibility(true, cy); cy.elements().removeClass('flag'); - cy.edges('![?color]').style({'underlay-opacity': 0}) - }) + cy.edges().not('[?color]').style({ 'underlay-opacity': 0 }); + }); - return cy.collection() + return cy.collection(); } } @@ -691,29 +921,31 @@ export class DiagramComponent implements AfterViewInit, OnDestroy { const trivials = cy.elements('.trivial'); if (visible) { - shadowNodes.style({opacity: 1}) - trivials.style({opacity: 1}) - shadowEdges.addClass('shadow') - cy.on('zoom', cy.data('reactome').interactivity.onZoom.shadow) - cy.data('reactome').interactivity.onZoom.shadow() + shadowNodes.style({ opacity: 1 }); + trivials.style({ opacity: 1 }); + shadowEdges.addClass('shadow'); + cy.on('zoom', cy.data('reactome').interactivity.onZoom.shadow); + cy.data('reactome').interactivity.onZoom.shadow(); } else { - shadowNodes.style({opacity: 0}) - shadowEdges.removeClass('shadow') + shadowNodes.style({ opacity: 0 }); + shadowEdges.removeClass('shadow'); //todo: This zoom handler is still being triggered and it adds a black underlay color to the edges. // this cy.off() method needs the exact same function references that's used in cy.on()? // Give opacity 0 for temporary fix in zoom handler - cy.off('zoom', cy.data('reactome').interactivity.onZoom.shadow) - trivials.style({opacity: 1}) - cy.edges().style({'underlay-opacity': 0}) + cy.off('zoom', cy.data('reactome').interactivity.onZoom.shadow); + trivials.style({ opacity: 1 }); + cy.edges().style({ 'underlay-opacity': 0 }); } } - - applyEvent(event: ReactomeEvent, affectedElements: cytoscape.NodeCollection | cytoscape.EdgeCollection) { + applyEvent( + event: ReactomeEvent, + affectedElements: cytoscape.NodeCollection | cytoscape.EdgeCollection + ) { switch (event.type) { case ReactomeEventTypes.hover: affectedElements.addClass('hover'); - this.hovering.set(true) + this.hovering.set(true); break; case ReactomeEventTypes.leave: affectedElements.removeClass('hover'); @@ -728,7 +960,6 @@ export class DiagramComponent implements AfterViewInit, OnDestroy { } } - ratio = 0.384; replacedElements!: cytoscape.SingularElementArgument[]; @@ -738,11 +969,12 @@ export class DiagramComponent implements AfterViewInit, OnDestroy { underlayPadding = 0; private updateReplacementVisibility() { - // // Calculate the position of the element that is to the right of the separation const extent = this.cyCompare!.extent(); - let limitIndex = this.replacedElementsPosition.findIndex(x1 => x1 >= extent.x1); + let limitIndex = this.replacedElementsPosition.findIndex( + (x1) => x1 >= extent.x1 + ); if (limitIndex === -1) limitIndex = this.replacedElements.length; /// Alternative calculation. In theory more optimised, but seems worse when console is opened for some reason @@ -764,33 +996,47 @@ export class DiagramComponent implements AfterViewInit, OnDestroy { if (this.lastIndex !== limitIndex) { // If at least one element is switched from left to right - if (limitIndex < this.lastIndex) this.replacedElements.slice(limitIndex, this.lastIndex) - .map(e => e.style('visibility', 'hidden')) // Hide the range of elements - .filter(e => e.is('.Shadow')) // And if it is an shadow - .forEach(shadow => shadow.data('edges').style('underlay-padding', 0)) // Hide as well the associated reaction underlay + if (limitIndex < this.lastIndex) + this.replacedElements + .slice(limitIndex, this.lastIndex) + .map((e) => e.style('visibility', 'hidden')) // Hide the range of elements + .filter((e) => e.is('.Shadow')) // And if it is an shadow + .forEach((shadow) => + shadow.data('edges').style('underlay-padding', 0) + ); // Hide as well the associated reaction underlay // If at least one element is switched from right to left - if (limitIndex > this.lastIndex) this.replacedElements.slice(this.lastIndex, limitIndex) - .map(e => e.style('visibility', 'visible')) // Show the range of elements - .filter(e => e.is('.Shadow')) // And if it is an shadow - .forEach(shadow => shadow.data('edges').style('underlay-padding', this.underlayPadding)) // Show as well the associated reaction underlay + if (limitIndex > this.lastIndex) + this.replacedElements + .slice(this.lastIndex, limitIndex) + .map((e) => e.style('visibility', 'visible')) // Show the range of elements + .filter((e) => e.is('.Shadow')) // And if it is an shadow + .forEach((shadow) => + shadow.data('edges').style('underlay-padding', this.underlayPadding) + ); // Show as well the associated reaction underlay } - this.lastIndex = limitIndex + this.lastIndex = limitIndex; } syncing = false; - syncViewports = (source: cytoscape.Core, sourceContainer: HTMLElement, target: cytoscape.Core, targetContainer: HTMLElement, overrideIgnore = false) => { + syncViewports = ( + source: cytoscape.Core, + sourceContainer: HTMLElement, + target: cytoscape.Core, + targetContainer: HTMLElement, + overrideIgnore = false + ) => { if (this.syncing && !overrideIgnore) return; if (!overrideIgnore) this.syncing = true; this.updateReplacementVisibility(); - const position = {...source.pan()}; + const position = { ...source.pan() }; const sourceX = sourceContainer.getBoundingClientRect().x; const targetX = targetContainer.getBoundingClientRect().x; position.x += sourceX - targetX; target.viewport({ zoom: source.zoom(), pan: position, - }) + }); if (!overrideIgnore) this.syncing = false; }; @@ -799,105 +1045,136 @@ export class DiagramComponent implements AfterViewInit, OnDestroy { if (!token || !diagramId) { this._loadAnalysisFn = undefined; - this.cys.forEach(cy => { + this.cys.forEach((cy) => { cy.batch(() => { cy.nodes().removeData('exp'); cy.edges('[?color]').style({ - 'underlay-padding': extract(this.reactomeStyle.properties.shadow.padding) + 'underlay-padding': extract( + this.reactomeStyle.properties.shadow.padding + ), }); cy.nodes('.Shadow').style({ 'font-size': extract(this.reactomeStyle.properties.shadow.fontSize), - 'text-outline-width': extract(this.reactomeStyle.properties.shadow.fontPadding) - }) - }) + 'text-outline-width': extract( + this.reactomeStyle.properties.shadow.fontPadding + ), + }); + }); }); - this.reactomeStyles.forEach(style => style.loadAnalysis(style.cy!, this.analysis.palette().scale)); - return + this.reactomeStyles.forEach((style) => + style.loadAnalysis(style.cy!, this.analysis.palette().scale) + ); + return; } forkJoin({ - entities: this.analysis.foundEntities(this.data.currentPathway()?.normalPathway?.stId || diagramId, token), - pathways: this.analysis.pathwaysResults(this.cy?.nodes('.Pathway').map(p => p.data('reactomeId')) || [], token), + entities: this.analysis.foundEntities( + this.data.currentPathway()?.normalPathway?.stId || diagramId, + token + ), + pathways: this.analysis.pathwaysResults( + this.cy?.nodes('.Pathway').map((p) => p.data('reactomeId')) || [], + token + ), result: this.analysis.result$.pipe(filter(isDefined), take(1)), - }).subscribe(({entities, pathways, result}) => { - + }).subscribe(({ entities, pathways, result }) => { this._loadAnalysisFn = (analysisIndex) => { - let analysisEntityMap = new Map(entities.entities.flatMap(entity => - entity.mapsTo - .flatMap(diagramEntity => diagramEntity.ids) - .map(id => [id, entity.exp[analysisIndex] || 0])) - ) + let analysisEntityMap = new Map( + entities.entities.flatMap((entity) => + entity.mapsTo + .flatMap((diagramEntity) => diagramEntity.ids) + .map((id) => [id, entity.exp[analysisIndex] || 0]) + ) + ); - let analysisPathwayMap = new Map(pathways.map(p => [p.dbId, p.entities])); + let analysisPathwayMap = new Map( + pathways.map((p) => [p.dbId, p.entities]) + ); const includeInteractors = result.summary.interactors; - this.cys.forEach(cy => { + this.cys.forEach((cy) => { cy.batch(() => { const style: Style = cy.data('reactome'); - cy.nodes('.InteractorOccurrences') - .forEach(occurence => { - if (includeInteractors) { - const interactors: Interactor[] = occurence.data('interactors'); - const exps = interactors.map(i => analysisEntityMap.get(i.acc)).filter(isDefined); - if (interactors && exps.length > 0) { - occurence.data('exp', [average(exps)]) - } else { - occurence.data('exp', [undefined]) - } + cy.nodes('.InteractorOccurrences').forEach((occurence) => { + if (includeInteractors) { + const interactors: Interactor[] = occurence.data('interactors'); + const exps = interactors + .map((i) => analysisEntityMap.get(i.acc)) + .filter(isDefined); + if (interactors && exps.length > 0) { + occurence.data('exp', [average(exps)]); } else { - occurence.removeData('exp') + occurence.data('exp', [undefined]); } - }); - + } else { + occurence.removeData('exp'); + } + }); - cy.nodes('.PhysicalEntity').forEach(node => { + cy.nodes('.PhysicalEntity').forEach((node) => { if (node.hasClass('Interactor') && !includeInteractors) return; // Avoid coloring interactors when analysis does not include them - const leaves: Graph.Node[] = node.data('graph.leaves') || [node.data('graph')]; + const leaves: Graph.Node[] = node.data('graph.leaves') || [ + node.data('graph'), + ]; const exp = leaves - ?.map(leaf => analysisEntityMap.get(leaf.identifier)) - ?.sort((a, b) => a !== undefined ? (b !== undefined ? a - b : -1) : 1); + ?.map((leaf) => analysisEntityMap.get(leaf.identifier)) + ?.sort((a, b) => + a !== undefined ? (b !== undefined ? a - b : -1) : 1 + ); // if (hasExpression) exp = exp.map(e => e !== undefined ? 1 - e : undefined); node.data('exp', exp); - }) - cy.nodes('.Pathway').forEach(node => { + }); + cy.nodes('.Pathway').forEach((node) => { const dbId: number = node.data('reactomeId'); const pathwayData = analysisPathwayMap.get(dbId); if (!pathwayData) { node.data('exp', [undefined]); } else { node.data('exp', [ - [pathwayData.exp[analysisIndex] || pathwayData.fdr, pathwayData.found], - [undefined, pathwayData.total - pathwayData.found] - ]) + [ + pathwayData.exp[analysisIndex] || pathwayData.fdr, + pathwayData.found, + ], + [undefined, pathwayData.total - pathwayData.found], + ]); } - }) + }); - cy.edges('[?color]').style({'underlay-padding': 8}); + cy.edges('[?color]').style({ 'underlay-padding': 8 }); cy.nodes('.Shadow').style({ 'font-size': extract(style.properties.shadow.fontSize) / 2, - 'text-outline-width': extract(style.properties.shadow.fontPadding) / 2 - }) + 'text-outline-width': + extract(style.properties.shadow.fontPadding) / 2, + }); - this.reactomeStyles.forEach(style => style.loadAnalysis(style.cy!, this.analysis.palette().scale)); - }) - }) - } + this.reactomeStyles.forEach((style) => + style.loadAnalysis(style.cy!, this.analysis.palette().scale) + ); + }); + }); + }; - this._loadAnalysisFn(this.analysis.sampleIndex()) - }) + this._loadAnalysisFn(this.analysis.sampleIndex()); + }); } - private _loadAnalysisFn: ((analysisIndex: number) => void) | undefined + private _loadAnalysisFn: ((analysisIndex: number) => void) | undefined; updateStyle() { - this.cy ? setTimeout(() => { - this.reactomeStyle?.update(this.cy); - this.thumbnailImg.set(this.cy.png({full: true, maxHeight: 240})) - }, 5) : null; - this.cyCompare ? setTimeout(() => this.reactomeStyle?.update(this.cyCompare), 5) : null; - this.legend ? setTimeout(() => this.reactomeStyle?.update(this.legend), 5) : null; + this.cy + ? setTimeout(() => { + this.reactomeStyle?.update(this.cy); + this.thumbnailImg.set(this.cy.png({ full: true, maxHeight: 240 })); + }, 5) + : null; + this.cyCompare + ? setTimeout(() => this.reactomeStyle?.update(this.cyCompare), 5) + : null; + this.legend + ? setTimeout(() => this.reactomeStyle?.update(this.legend), 5) + : null; } compareDragging = false; @@ -910,35 +1187,48 @@ export class DiagramComponent implements AfterViewInit, OnDestroy { this.compareDragging = false; } - dragMove($event: MouseEvent | TouchEvent, compareContainer: HTMLDivElement, container: HTMLDivElement) { + dragMove( + $event: MouseEvent | TouchEvent, + compareContainer: HTMLDivElement, + container: HTMLDivElement + ) { if (!this.compareDragging) return; - const x = $event instanceof TouchEvent ? $event.touches[0].clientX : $event.x; - compareContainer.style['left'] = x - container.getBoundingClientRect().x + 'px'; - this.cyCompare.resize() - this.syncViewports(this.cy!, this.cytoscapeContainer!.nativeElement, this.cyCompare!, this.compareContainer!.nativeElement); + const x = + $event instanceof TouchEvent ? $event.touches[0].clientX : $event.x; + compareContainer.style['left'] = + x - container.getBoundingClientRect().x + 'px'; + this.cyCompare.resize(); + this.syncViewports( + this.cy!, + this.cytoscapeContainer!.nativeElement, + this.cyCompare!, + this.compareContainer!.nativeElement + ); } - legendPosition = signal({x:0, y:0}); + legendPosition = signal({ x: 0, y: 0 }); animateLegend = signal(false); updateLegend() { - this.legend.resize() - this.legend.panningEnabled(true) - this.legend.zoomingEnabled(true) - this.legend.fit(this.legend.elements(), 2) - this.legend.panningEnabled(false) - this.legend.zoomingEnabled(false) + this.legend.resize(); + this.legend.panningEnabled(true); + this.legend.zoomingEnabled(true); + this.legend.fit(this.legend.elements(), 2); + this.legend.panningEnabled(false); + this.legend.zoomingEnabled(false); } toggleLegend(legendWidth: number) { this.animateLegend.set(true); - this.legendPosition().x <= -legendWidth + 5 ? this.legendPosition.set({x: 0, y: 0}) : this.legendPosition.set({x: -legendWidth, y: 0}) - this.updateLegend() - setTimeout(() => this.animateLegend.set(false), 500) + this.legendPosition().x <= -legendWidth + 5 + ? this.legendPosition.set({ x: 0, y: 0 }) + : this.legendPosition.set({ x: -legendWidth, y: 0 }); + this.updateLegend(); + setTimeout(() => this.animateLegend.set(false), 500); } - // ----- Event Syncing ----- - private _reactomeEvents$: Subject = new Subject(); + private _reactomeEvents$: Subject = + new Subject(); private _ignore = false; @@ -949,12 +1239,18 @@ export class DiagramComponent implements AfterViewInit, OnDestroy { } @Output() - public reactomeEvents$: Observable = this._reactomeEvents$.asObservable().pipe( - distinctUntilChanged((prev, current) => prev.type === current.type && prev.detail.reactomeId === current.detail.reactomeId), - // tap(e => console.log(e.type, e.detail, e.detail.element.data(), e.detail.cy.container()?.id)), - filter(() => !this._ignore), - share() - ); + public reactomeEvents$: Observable = this._reactomeEvents$ + .asObservable() + .pipe( + distinctUntilChanged( + (prev, current) => + prev.type === current.type && + prev.detail.reactomeId === current.detail.reactomeId + ), + // tap(e => console.log(e.type, e.detail, e.detail.element.data(), e.detail.cy.container()?.id)), + filter(() => !this._ignore), + share() + ); private stateToDiagram() { for (let cy of this.cys) { @@ -965,151 +1261,203 @@ export class DiagramComponent implements AfterViewInit, OnDestroy { const resource = this.state.overlay(); if (resource) { //console.log('Resource not null', resource) - this.interactorsComponent()?.getInteractors(resource) + this.interactorsComponent()?.getInteractors(resource); } - this.loadAnalysis(this.state.analysis()) + this.loadAnalysis(this.state.analysis()); } - compareBackgroundSync = this.reactomeEvents$.pipe( - filter(() => this.comparing), - filter((e) => e.detail.cy !== this.legend) - ).subscribe(event => { - const src = event.detail.cy; - const tgt = src === this.cy ? this.cyCompare : this.cy; - - let replacedBy = event.detail.element.data('replacedBy'); - replacedBy = replacedBy || event.detail.element.data('replacement'); - replacedBy = replacedBy || (event.detail.element.data('isBackground') && !event.detail.element.data('isFadeOut') && event.detail.element.data('id')); - - if (!replacedBy) return; - - let replacements = tgt.getElementById(replacedBy); - if (event.detail.type === 'reaction') { - replacements = replacements.add(tgt.elements(`[reactionId=${replacedBy}]`)) - } - - this.applyEvent(event, replacements) - }); + compareBackgroundSync = this.reactomeEvents$ + .pipe( + filter(() => this.comparing), + filter((e) => e.detail.cy !== this.legend) + ) + .subscribe((event) => { + const src = event.detail.cy; + const tgt = src === this.cy ? this.cyCompare : this.cy; + + let replacedBy = event.detail.element.data('replacedBy'); + replacedBy = replacedBy || event.detail.element.data('replacement'); + replacedBy = + replacedBy || + (event.detail.element.data('isBackground') && + !event.detail.element.data('isFadeOut') && + event.detail.element.data('id')); + + if (!replacedBy) return; + + let replacements = tgt.getElementById(replacedBy); + if (event.detail.type === 'reaction') { + replacements = replacements.add( + tgt.elements(`[reactionId=${replacedBy}]`) + ); + } + this.applyEvent(event, replacements); + }); interactorOpeningHandling = this.reactomeEvents$ .pipe( filter((e) => e.detail.cy !== this.legend), - filter(e => [ReactomeEventTypes.open, ReactomeEventTypes.close].includes(e.type as ReactomeEventTypes)), - filter(e => e.detail.type === 'Interactor'), - ).subscribe(e => { - [this.reactomeStyle, this.reactomeStyleCompare] - .filter(s => s !== undefined && e.detail.cy === s.cy) - .forEach(style => { - const occurrenceNode = e.detail.element.nodes()[0]; - - if (e.type === ReactomeEventTypes.open) - this.interactorsService.addInteractorNodes(occurrenceNode, style.cy!); - else - this.interactorsService.removeInteractorNodes(occurrenceNode); - - style.interactivity.updateProteins(); - style.interactivity.triggerZoom(); - } - ) + filter((e) => + [ReactomeEventTypes.open, ReactomeEventTypes.close].includes( + e.type as ReactomeEventTypes + ) + ), + filter((e) => e.detail.type === 'Interactor') + ) + .subscribe((e) => { + [this.reactomeStyle, this.reactomeStyleCompare] + .filter((s) => s !== undefined && e.detail.cy === s.cy) + .forEach((style) => { + const occurrenceNode = e.detail.element.nodes()[0]; + + if (e.type === ReactomeEventTypes.open) + this.interactorsService.addInteractorNodes( + occurrenceNode, + style.cy! + ); + else this.interactorsService.removeInteractorNodes(occurrenceNode); - if (this.comparing) { - this.initialiseReplaceElements(); - } + style.interactivity.updateProteins(); + style.interactivity.triggerZoom(); + }); - if (this._loadAnalysisFn) this._loadAnalysisFn(this.analysis.sampleIndex()); + if (this.comparing) { + this.initialiseReplaceElements(); } - ); - diagram2legend = this.reactomeEvents$.pipe( - filter((e) => e.detail.cy !== this.legend), - ).subscribe(event => { - const classes = event.detail.element.classes(); - const firstClassToMatch = classes[0]; - - // Only get the first matched item in the classes, this help to filter out the polymer when hovering on a molecule - let matchingElement: cytoscape.NodeCollection | cytoscape.EdgeCollection = this.legend.elements(`.${firstClassToMatch}`).filter(ele => { - let classes = ele.classes(); - return Array.isArray(classes) && classes[0] === firstClassToMatch; - }) - - if (event.detail.type === SchemaClasses.PE) { - if (classes.includes('drug')) matchingElement = matchingElement.nodes('.drug') - else matchingElement = matchingElement.not('.drug') - } else if (event.detail.type === 'reaction') { - const reaction = event.detail.element.nodes('.reaction'); - matchingElement = this.legend.nodes(`.${reaction.classes()[0]}`).first() - matchingElement = matchingElement.add(matchingElement.connectedEdges()) - } + if (this._loadAnalysisFn) + this._loadAnalysisFn(this.analysis.sampleIndex()); + }); - this._ignore = true; - this.applyEvent(event, matchingElement); - this._ignore = false; - }); + diagram2legend = this.reactomeEvents$ + .pipe(filter((e) => e.detail.cy !== this.legend)) + .subscribe((event) => { + const classes = event.detail.element.classes(); + const firstClassToMatch = classes[0]; + + // Only get the first matched item in the classes, this help to filter out the polymer when hovering on a molecule + let matchingElement: cytoscape.NodeCollection | cytoscape.EdgeCollection = + this.legend.elements(`.${firstClassToMatch}`).filter((ele) => { + let classes = ele.classes(); + return Array.isArray(classes) && classes[0] === firstClassToMatch; + }); + + if (event.detail.type === SchemaClasses.PE) { + if (classes.includes('drug')) + matchingElement = matchingElement.nodes('.drug'); + else matchingElement = matchingElement.not('.drug'); + } else if (event.detail.type === 'reaction') { + const reaction = event.detail.element.nodes('.reaction'); + matchingElement = this.legend + .nodes(`.${reaction.classes()[0]}`) + .first(); + matchingElement = matchingElement.add(matchingElement.connectedEdges()); + } - diagramSelect2state = this.reactomeEvents$.pipe( - filter((e) => e.detail.cy !== this.legend && e.type === ReactomeEventTypes.select), - // filter(e => e.detail.cy !== this.cy), - delay(5), // allow for unselect to be processed before select when clicking on an already selected element - ).subscribe(e => { + this._ignore = true; + this.applyEvent(event, matchingElement); + this._ignore = false; + }); + + diagramSelect2state = this.reactomeEvents$ + .pipe( + filter( + (e) => + e.detail.cy !== this.legend && e.type === ReactomeEventTypes.select + ), + // filter(e => e.detail.cy !== this.cy), + delay(5) // allow for unselect to be processed before select when clicking on an already selected element + ) + .subscribe((e) => { let elements: cytoscape.NodeSingular = e.detail.element; - const reactomeIds = elements.map(el => el.data('graph.stId')); - this.selecting = true + const reactomeIds = elements.map((el) => el.data('graph.stId')); + this.selecting = true; this.state.select.set(reactomeIds[0]); - } - ); + }); - diagramUnselect2state = this.reactomeEvents$.pipe( - filter((e) => e.detail.cy !== this.legend && e.type === ReactomeEventTypes.unselect) - ).subscribe(e => { - if (this.state.select() === e.detail.element.data('graph.stId')) { - //console.log('Unselect', e.detail.reactomeId) - this.state.select.set(null) - } - }) - - legend2state = this.reactomeEvents$.pipe( - filter((e) => e.detail.cy === this.legend), - filter(() => !this._ignore), - distinctUntilChanged((previous, next) => next.detail.element.id() === previous.detail.element.id() && next.type === previous.type), - ).subscribe((e) => { - const event = e as ReactomeEvent; - const classes = event.detail.element.classes(); - for (let cy of [this.cy, this.cyCompare].filter(isDefined)) { - let matchingElement: cytoscape.NodeCollection | cytoscape.EdgeCollection = cy.elements(`.${classes[0]}`); - - // TODO move everything to use state - - if (event.detail.type === 'PhysicalEntity' || event.detail.type === 'Pathway') { - if (classes.includes('drug')) matchingElement = matchingElement.nodes('.drug') - else matchingElement = matchingElement.not('.drug') - } else if (event.detail.type === 'reaction') { - const reaction = event.detail.element.nodes('.reaction'); - matchingElement = this.cy.nodes(`.${reaction.classes()[0]}`) - matchingElement = matchingElement.add(matchingElement.connectedEdges()) + diagramUnselect2state = this.reactomeEvents$ + .pipe( + filter( + (e) => + e.detail.cy !== this.legend && e.type === ReactomeEventTypes.unselect + ) + ) + .subscribe((e) => { + if (this.state.select() === e.detail.element.data('graph.stId')) { + //console.log('Unselect', e.detail.reactomeId) + this.state.select.set(null); } + }); + + legend2state = this.reactomeEvents$ + .pipe( + filter((e) => e.detail.cy === this.legend), + filter(() => !this._ignore), + distinctUntilChanged( + (previous, next) => + next.detail.element.id() === previous.detail.element.id() && + next.type === previous.type + ) + ) + .subscribe((e) => { + const event = e as ReactomeEvent; + const classes = event.detail.element.classes(); + for (let cy of [this.cy, this.cyCompare].filter(isDefined)) { + let matchingElement: + | cytoscape.NodeCollection + | cytoscape.EdgeCollection = cy.elements(`.${classes[0]}`); + + // TODO move everything to use state + + if ( + event.detail.type === 'PhysicalEntity' || + event.detail.type === 'Pathway' + ) { + if (classes.includes('drug')) + matchingElement = matchingElement.nodes('.drug'); + else matchingElement = matchingElement.not('.drug'); + } else if (event.detail.type === 'reaction') { + const reaction = event.detail.element.nodes('.reaction'); + matchingElement = this.cy.nodes(`.${reaction.classes()[0]}`); + matchingElement = matchingElement.add( + matchingElement.connectedEdges() + ); + } - switch (event.type) { - case ReactomeEventTypes.select: - this.flagging = true - this.state.flag.set(['class:' + classes[0] + (event.detail.type === 'reaction' ? '' : ((classes.includes('drug') ? '.' : '!') + 'drug'))]); - break; - case ReactomeEventTypes.unselect: - this.flagging = true - this.state.flag.set([]); - break; - case ReactomeEventTypes.hover: - matchingElement.addClass('hover') - break; - case ReactomeEventTypes.leave: - matchingElement.removeClass('hover') - break; + switch (event.type) { + case ReactomeEventTypes.select: + this.flagging = true; + this.state.flag.set([ + 'class:' + + classes[0] + + (event.detail.type === 'reaction' + ? '' + : (classes.includes('drug') ? '.' : '!') + 'drug'), + ]); + break; + case ReactomeEventTypes.unselect: + this.flagging = true; + this.state.flag.set([]); + break; + case ReactomeEventTypes.hover: + matchingElement.addClass('hover'); + break; + case ReactomeEventTypes.leave: + matchingElement.removeClass('hover'); + break; + } } - } - }); + }); logProteins() { - console.debug(new Set(this.cy.nodes(".Protein").map(node => node.data("acc") || node.data("iAcc")))) + console.debug( + new Set( + this.cy + .nodes('.Protein') + .map((node) => node.data('acc') || node.data('iAcc')) + ) + ); } } diff --git a/projects/pathway-browser/src/app/event-hierarchy/event-hierarchy.component.html b/projects/pathway-browser/src/app/event-hierarchy/event-hierarchy.component.html index 77e0125..1c87338 100644 --- a/projects/pathway-browser/src/app/event-hierarchy/event-hierarchy.component.html +++ b/projects/pathway-browser/src/app/event-hierarchy/event-hierarchy.component.html @@ -1,6 +1,6 @@