diff --git a/angular.json b/angular.json index ea330f1..8ea6b18 100644 --- a/angular.json +++ b/angular.json @@ -152,6 +152,11 @@ "glob": "**/*", "input": "projects/website-angular/src/assets", "output": "/assets" + }, + { + "glob": "**/*", + "input": "projects/pathway-browser/src/assets", + "output": "/assets" } ], "styles": ["projects/website-angular/src/styles.scss"], diff --git a/projects/pathway-browser/src/app/details/common/reaction-diagram/reaction-diagram.component.html b/projects/pathway-browser/src/app/details/common/reaction-diagram/reaction-diagram.component.html new file mode 100644 index 0000000..0da0217 --- /dev/null +++ b/projects/pathway-browser/src/app/details/common/reaction-diagram/reaction-diagram.component.html @@ -0,0 +1 @@ +
diff --git a/projects/pathway-browser/src/app/details/common/reaction-diagram/reaction-diagram.component.scss b/projects/pathway-browser/src/app/details/common/reaction-diagram/reaction-diagram.component.scss new file mode 100644 index 0000000..f8def93 --- /dev/null +++ b/projects/pathway-browser/src/app/details/common/reaction-diagram/reaction-diagram.component.scss @@ -0,0 +1,13 @@ +:host { + display: block; + width: 100%; +} + +.reaction-diagram-container { + position: relative; + width: 100%; + height: 500px; + border: 1px solid var(--outline-variant); + border-radius: 8px; + overflow: hidden; +} diff --git a/projects/pathway-browser/src/app/details/common/reaction-diagram/reaction-diagram.component.ts b/projects/pathway-browser/src/app/details/common/reaction-diagram/reaction-diagram.component.ts new file mode 100644 index 0000000..15f8a8e --- /dev/null +++ b/projects/pathway-browser/src/app/details/common/reaction-diagram/reaction-diagram.component.ts @@ -0,0 +1,71 @@ +import { + AfterViewInit, + Component, + ElementRef, + inject, + input, + OnDestroy, + viewChild +} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {Style} from 'reactome-cytoscape-style'; +import cytoscape from 'cytoscape'; +import {Diagram} from '../../../model/diagram.model'; +import {Graph} from '../../../model/graph.model'; +import {DiagramService} from '../../../services/diagram.service'; +import {CONTENT_SERVICE} from '../../../../environments/environment'; + +interface ReactionJson { + diagram: Diagram; + graph: Graph.Data; +} + +@Component({ + selector: 'cr-reaction-diagram', + templateUrl: './reaction-diagram.component.html', + styleUrl: './reaction-diagram.component.scss', +}) +export class ReactionDiagramComponent implements AfterViewInit, OnDestroy { + private http = inject(HttpClient); + private diagramService = inject(DiagramService); + + readonly stId = input.required(); + + private containerRef = viewChild.required>('container'); + private cy?: cytoscape.Core; + private reactomeStyle?: Style; + + ngAfterViewInit() { + const container = this.containerRef().nativeElement; + this.reactomeStyle = new Style(container); + + this.http.get(`${CONTENT_SERVICE}/exporter/reaction/${this.stId()}/diagram`) + .subscribe(({diagram, graph}) => { + // Ensure required arrays exist (reaction diagrams may omit empty arrays) + diagram.links = diagram.links || []; + diagram.shadows = diagram.shadows || []; + diagram.compartments = diagram.compartments || []; + graph.subpathways = graph.subpathways || []; + + const elements = this.diagramService.diagramFromData(diagram, graph); + + this.cy = cytoscape({ + container, + elements, + style: this.reactomeStyle?.getStyleSheet(), + layout: {name: 'preset'}, + boxSelectionEnabled: false, + }); + + this.reactomeStyle?.bindToCytoscape(this.cy); + this.cy.fit(undefined, 20); + this.cy.userZoomingEnabled(true); + this.cy.userPanningEnabled(true); + this.cy.autoungrabify(true); + }); + } + + ngOnDestroy() { + this.cy?.destroy(); + } +} diff --git a/projects/pathway-browser/src/app/details/tabs/description-tab/description-tab.component.html b/projects/pathway-browser/src/app/details/tabs/description-tab/description-tab.component.html index 3a5733f..a7cc73e 100644 --- a/projects/pathway-browser/src/app/details/tabs/description-tab/description-tab.component.html +++ b/projects/pathway-browser/src/app/details/tabs/description-tab/description-tab.component.html @@ -244,13 +244,13 @@
- @for (item of authorship(); track item) { + @for (item of authorship(); track item.label) {
{{ item.label }}
- @for (ie of item.data |sortByDate:'dataTime'; track ie.dbId) { + @for (ie of item.data; track $index) {
- @for (author of ie.author; let i = $index; track author.dbId) { + @for (author of ie.author; let i = $index; track $index) { {{ author.firstname }} {{ author.surname }} @@ -281,3 +281,15 @@
+ +
+ +
+
+ + +
+ +
+
+ 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 6ffd965..3b1e411 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 @@ -50,6 +50,11 @@ import {SpeciesService} from "../../../services/species.service"; import {Summation} from "../../../model/graph/summation.model"; import {FigureService} from "./figure/figure.service"; import 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"; @@ -68,6 +73,12 @@ import {CellMarkerComponent} from "../../common/cell-marker/cell-marker.componen 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({ @@ -76,6 +87,13 @@ import {InteractorsTableComponent} from "../../common/interactors-table/interact styleUrl: './description-tab.component.scss', standalone: true, imports: [ + NgTemplateOutlet, + NgClass, + KeyValuePipe, + RouterLink, + SortByTextPipe, + IncludeRefPipe, + AuthorshipDateFormatPipe, MatDivider, MatIcon, MatTooltip, @@ -92,7 +110,9 @@ import {InteractorsTableComponent} from "../../common/interactors-table/interact CellMarkerComponent, IconComponent, RheaComponent, - InteractorsTableComponent + InteractorsTableComponent, + LocationsTreeComponent, + ReactionDiagramComponent ] }) export class DescriptionTabComponent implements OnDestroy { @@ -124,6 +144,7 @@ export class DescriptionTabComponent implements OnDestroy { readonly obj = input.required(); readonly analysisResult = input(); + readonly showLocations = input(false); static referenceTypeToNameSuffix = new Map([ ["ReferenceMolecule", ""], @@ -171,21 +192,23 @@ export class DescriptionTabComponent implements OnDestroy { referenceEntity: Signal = computed(() => getProperty(this.obj(), DataKeys.REFERENCE_ENTITY)); - readonly authorship: Signal<{ label: string, data: InstanceEdit[] }[]> = computed(() => { + 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 finalAuthored = arrayWrap(getProperty(obj, DataKeys.AUTHORED) || getProperty(obj, DataKeys.CREATED) || []); + 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 [ - ...(finalAuthored.length > 0 ? [{label: Labels.AUTHOR, data: finalAuthored}] : []), + ...(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}] : []), ]; }); @@ -260,6 +283,10 @@ export class DescriptionTabComponent implements OnDestroy { authorsTemplate$ = viewChild.required>('authorsTemplate'); interactorsTemplate$ = viewChild.required>('interactorsTemplate'); rheaTemplate$ = viewChild.required>('rheaTemplate'); + locationsTemplate$ = viewChild>('locationsTemplate'); + reactionDiagramTemplate$ = viewChild>('reactionDiagramTemplate'); + + readonly isReaction = computed(() => isRLE(this.obj())); protected readonly Labels = Labels; protected readonly DataKeys = DataKeys; @@ -287,6 +314,20 @@ export class DescriptionTabComponent implements OnDestroy { template: this.overviewTemplate$, isPresent: signal(true) }, + { + key: 'locationsInPWB', + label: 'Locations in the Pathway Browser', + manual: true, + template: this.locationsTemplate$ as Signal>, + isPresent: computed(() => this.showLocations()) + }, + { + key: 'reactionDiagram', + label: 'Reaction Diagram', + manual: true, + template: this.reactionDiagramTemplate$ as Signal>, + isPresent: this.isReaction + }, {key: DataKeys.REFERENCE_ENTITY, label: Labels.EXTERNAL_REFERENCE, manual: true, template: this.referenceTemplate$}, {key: DataKeys.SUMMARISED_ENTITIES, label: Labels.SUMMARISED_ENTITIES}, { @@ -437,6 +478,10 @@ export class DescriptionTabComponent implements OnDestroy { switch (key) { case DataKeys.OVERVIEW: return obj; + case 'locationsInPWB': + return this.showLocations(); + case 'reactionDiagram': + return this.isReaction(); case DataKeys.PROTEIN_MARKER: return this.proteinMarkers().length + this.rnaMarkers().length > 0; case DataKeys.CATALYST_ACTIVITY: diff --git a/projects/pathway-browser/src/app/details/tabs/result-tab/result-tab.component.ts b/projects/pathway-browser/src/app/details/tabs/result-tab/result-tab.component.ts index c045e9b..c1baa24 100644 --- a/projects/pathway-browser/src/app/details/tabs/result-tab/result-tab.component.ts +++ b/projects/pathway-browser/src/app/details/tabs/result-tab/result-tab.component.ts @@ -277,7 +277,7 @@ export class ResultTabComponent { visitPathway(pathway: Analysis.Pathway) { this.data.selectedPathwayStId.set(pathway.stId) console.log("Navigating to " + pathway.stId) - this.router.navigate([pathway.stId], {queryParamsHandling: 'preserve', preserveFragment: true}) + this.state.navigateTo(pathway.stId, {queryParamsHandling: 'preserve', preserveFragment: true}) } diff --git a/projects/pathway-browser/src/app/diagram/diagram.component.ts b/projects/pathway-browser/src/app/diagram/diagram.component.ts index a92f11f..6dbc6c9 100644 --- a/projects/pathway-browser/src/app/diagram/diagram.component.ts +++ b/projects/pathway-browser/src/app/diagram/diagram.component.ts @@ -397,12 +397,12 @@ export class DiagramComponent implements AfterViewInit, OnDestroy { this.cy.on('zoom', () => this.controlZoom.set(this.zoomToControlTransform(this.cy.zoom()))); this.reactomeStyle.clearCache(); - this.cy.on('dblclick', '.SUB.Pathway', (e) => this.router.navigate([e.target.data('graph.stId')], { + 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.router.navigate([e.target.data('graph.stId')], { + this.cy.on('dblclick', '.Interacting.Pathway', (e) => this.state.navigateTo(e.target.data('graph.stId'), { queryParams: {select: this.pathwayId()}, queryParamsHandling: "merge", preserveFragment: true diff --git a/projects/pathway-browser/src/app/event-hierarchy/event-hierarchy.component.ts b/projects/pathway-browser/src/app/event-hierarchy/event-hierarchy.component.ts index 53b8d1e..762ac02 100644 --- a/projects/pathway-browser/src/app/event-hierarchy/event-hierarchy.component.ts +++ b/projects/pathway-browser/src/app/event-hierarchy/event-hierarchy.component.ts @@ -3,7 +3,7 @@ import {Event} from "../model/graph/event/event.model"; import {EventService, SelectableObject} from "../services/event.service"; import {SpeciesService} from "../services/species.service"; import {combineLatest, combineLatestWith, filter, fromEvent, map, Observable, of, switchMap, take, tap} from "rxjs"; -import {MatTree, MatTreeNestedDataSource, MatTreeNodeDef} from "@angular/material/tree"; +import {MatTree, MatTreeNestedDataSource, MatTreeNodeDef, MatTreeNodeOutlet, MatTreeNodeToggle} from "@angular/material/tree"; import {UrlStateService} from "../services/url-state.service"; import {SplitComponent} from "angular-split"; import {UntilDestroy, untilDestroyed} from "@ngneat/until-destroy"; @@ -35,6 +35,8 @@ import {PassiveDirective} from "../utils/passive.directive"; MatTree, MatNestedTreeNode, MatTreeNodeDef, + MatTreeNodeToggle, + MatTreeNodeOutlet, MatButton, MatIconButton, NgClass, @@ -168,11 +170,15 @@ export class EventHierarchyComponent implements AfterViewInit, OnDestroy { }, 100); this.eventService.treeData$.pipe(untilDestroyed(this)).subscribe(events => { + // Save expanded node stIds before resetting the data source + const expandedIds = this.collectExpandedIds(this.treeDataSource.data); // @ts-ignore // Mat tree has a bug causing children to not be rendered in the UI without first setting the data to null // This is a workaround to add child data to tree and update the view. see details: https://github.com/angular/components/issues/11381 this.treeDataSource.data = []; //todo: check performance issue this.treeDataSource.data = events as Event[]; + // Restore expansion state + this.restoreExpandedIds(events as Event[], expandedIds); this.adjustWidths(); }); @@ -393,20 +399,16 @@ export class EventHierarchyComponent implements AfterViewInit, OnDestroy { const selectedEventId = isPathway(treeEvent) && treeEvent.hasDiagram ? null : treeEvent.stId; this._ignore = true; // this.speciesService.setIgnore(true); - this.router.navigate([diagramId], { + this.state.navigateTo(diagramId ?? null, { queryParamsHandling: "preserve" // Keep existing query params }).then(() => { this.state.select.set(selectedEventId); this.eventService.setCurrentTreeEvent(treeEvent); - // Listen for NavigationEnd event to reset _ignore - this.router.events.pipe( - filter(routerEvent => routerEvent instanceof NavigationEnd), - take(1) // Take the first NavigationEnd event and unsubscribe automatically - ).subscribe(() => { - this._ignore = false; - // this.speciesService.setIgnore(false); - }); - + // Reset _ignore after setting state. The .then() callback fires after + // NavigationEnd has already been emitted, so waiting for a future + // NavigationEnd would leave _ignore stuck at true if no further + // navigation occurs. + this._ignore = false; }).catch(err => { throw new Error('Navigation error:', err); }); @@ -530,4 +532,35 @@ export class EventHierarchyComponent implements AfterViewInit, OnDestroy { labelSpan.classList.remove('add-overflowX'); el.classList.remove('no-transition'); } + + private collectExpandedIds(nodes: Event[]): Set { + const expanded = new Set(); + const traverse = (items: Event[]) => { + for (const node of items) { + if (this.tree?.isExpanded(node)) { + expanded.add(node.stId); + } + if (isPathway(node) && node.events) { + traverse(node.events.map(e => e.element)); + } + } + }; + traverse(nodes); + return expanded; + } + + private restoreExpandedIds(nodes: Event[], expandedIds: Set): void { + if (expandedIds.size === 0) return; + const traverse = (items: Event[]) => { + for (const node of items) { + if (expandedIds.has(node.stId)) { + this.tree?.expand(node); + } + if (isPathway(node) && node.events) { + traverse(node.events.map(e => e.element)); + } + } + }; + traverse(nodes); + } } diff --git a/projects/pathway-browser/src/app/model/diagram.model.ts b/projects/pathway-browser/src/app/model/diagram.model.ts index b9d69eb..f934e9a 100644 --- a/projects/pathway-browser/src/app/model/diagram.model.ts +++ b/projects/pathway-browser/src/app/model/diagram.model.ts @@ -80,7 +80,7 @@ export interface NodeConnector { type: 'INPUT' | 'OUTPUT' | 'CATALYST' | 'ACTIVATOR' | 'INHIBITOR'; segments: Segment[] stoichiometry: { value: number } - endShape: { centre: Position } + endShape: { centre: Position, c?: Position } isFadeOut?: boolean } diff --git a/projects/pathway-browser/src/app/reacfoam/reacfoam.component.ts b/projects/pathway-browser/src/app/reacfoam/reacfoam.component.ts index af22197..77813bd 100644 --- a/projects/pathway-browser/src/app/reacfoam/reacfoam.component.ts +++ b/projects/pathway-browser/src/app/reacfoam/reacfoam.component.ts @@ -108,7 +108,7 @@ export class ReacfoamComponent implements OnDestroy { onGroupDoubleClick: (event: any) => { event.preventDefault(); - this.router.navigate([event.group.stId], {queryParamsHandling: 'preserve', preserveFragment: true}) + this.state.navigateTo(event.group.stId, {queryParamsHandling: 'preserve', preserveFragment: true}) }, onGroupClick: (event: any) => { diff --git a/projects/pathway-browser/src/app/services/diagram.service.ts b/projects/pathway-browser/src/app/services/diagram.service.ts index b05923a..60e6019 100644 --- a/projects/pathway-browser/src/app/services/diagram.service.ts +++ b/projects/pathway-browser/src/app/services/diagram.service.ts @@ -243,434 +243,441 @@ export class DiagramService { ) ) })), - map(({diagram, graph, chebiMapping}) => { - console.log("edge.reactionType", new Set(diagram.edges.flatMap(edge => edge.reactionType))) - console.log("node.connectors.types", new Set(diagram.nodes.flatMap(node => node.connectors.flatMap(con => con.type)))) - console.log("node.renderableClass", new Set(diagram.nodes.flatMap(node => node.renderableClass))) - console.log("links.renderableClass", new Set(diagram.links.flatMap(link => link.renderableClass))) - console.log("shadow.renderableClass", new Set(diagram.shadows.flatMap(shadow => shadow.renderableClass))) - - const idToEdges = new Map(diagram.edges.map(edge => [edge.id, edge])); - const idToNodes = new Map(diagram.nodes.map(node => [node.id, node])); - const reactomeIdToEdge = new Map( - [ - // ...diagram.nodes.map(node => [node.reactomeId, node]), - ...diagram.edges.map(edge => [edge.reactomeId, edge]) - ] as [number, Edge][] - ); - - const edgeIds = new Map(); - const forwardArray = diagram.edges.flatMap(edge => edge.segments.map(segment => [posToStr(edge, scale(segment.from)), scale(segment.to)])) as [string, Position][]; - this.extraLine = new Map(forwardArray); - console.assert(forwardArray.length === this.extraLine.size, "Some edge diagram have been lost because 2 segments are starting from the same point") - - const backwardArray = diagram.edges.flatMap(edge => edge.segments.map(segment => [posToStr(edge, scale(segment.to)), scale(segment.from)])) as [string, Position][]; - this.reverseExtraLine = new Map(backwardArray); - console.assert(backwardArray.length == this.reverseExtraLine.size, "Some edge diagram have been lost because 2 segments are ending at the same point") - - - const subpathwayIds = new Set(diagram.shadows.map((shadow) => shadow.reactomeId)) - - const eventIdToSubPathwayId = new Map(graph.subpathways?.flatMap(subpathway => subpathway.events - .map(event => [event, subpathway.dbId]) - .filter(entry => subpathwayIds.has(entry[1]))) as [number, number][] || []) - - const subpathwayIdToEventIds = new Map(graph.subpathways?.map(subpathway => [subpathway.dbId, subpathway.events])); - const subpathwayStIdToEventIds = new Map(graph.subpathways?.map(subpathway => [subpathway.stId, subpathway.events])); - // create a node id - graph node mapping - const dbIdToGraphNode = new Map(graph.nodes.map(node => ([node.dbId, node]))) - const mappingList: [number, Graph.Node][] = graph.nodes.flatMap(node => { - if (node.children && node.children.length === 1 && node.diagramIds?.length !== 1) { // Consider homomer complex like their constituents for interactors - return node.diagramIds?.map(id => [id, dbIdToGraphNode.get(node.children[0])]) - .filter(entry => entry[1] !== undefined) as [number, Graph.Node][] - } else { - return node.diagramIds?.map(id => [id, node]) as [number, Graph.Node][] - } - }).filter(entry => entry !== undefined); + map(({diagram, graph, chebiMapping}) => this.diagramFromData(diagram, graph, id, chebiMapping)), + tap((output) => console.log('Output:', output)), + ) - const idToGraphNodes = new Map([...mappingList]); - const idToGraphEdges = new Map(graph.edges.map(edge => [edge.dbId, edge])); + } - const getLeaves = (node: Graph.Node, leaves: Set) => { - if (node.children && node.children.length > 0) - node.children.forEach(child => getLeaves(dbIdToGraphNode.get(child)!, leaves)) - else - leaves.add(node); - } + public diagramFromData( + diagram: Diagram, + graph: Graph.Data, + id: number | string = '', + chebiMapping: Map> = new Map() + ): cytoscape.ElementsDefinition { + console.log("edge.reactionType", new Set(diagram.edges.flatMap(edge => edge.reactionType))) + console.log("node.connectors.types", new Set(diagram.nodes.flatMap(node => node.connectors.flatMap(con => con.type)))) + console.log("node.renderableClass", new Set(diagram.nodes.flatMap(node => node.renderableClass))) + console.log("links.renderableClass", new Set(diagram.links.flatMap(link => link.renderableClass))) + console.log("shadow.renderableClass", new Set(diagram.shadows.flatMap(shadow => shadow.renderableClass))) + + const idToEdges = new Map(diagram.edges.map(edge => [edge.id, edge])); + const idToNodes = new Map(diagram.nodes.map(node => [node.id, node])); + const reactomeIdToEdge = new Map( + [ + // ...diagram.nodes.map(node => [node.reactomeId, node]), + ...diagram.edges.map(edge => [edge.reactomeId, edge]) + ] as [number, Edge][] + ); - idToGraphNodes.forEach(node => { - if (node.children?.length > 0) { - let leaves = new Set(); - getLeaves(node, leaves); - node.leaves = [...leaves]; - } + const edgeIds = new Map(); + const forwardArray = diagram.edges.flatMap(edge => edge.segments.map(segment => [posToStr(edge, scale(segment.from)), scale(segment.to)])) as [string, Position][]; + this.extraLine = new Map(forwardArray); + console.assert(forwardArray.length === this.extraLine.size, "Some edge diagram have been lost because 2 segments are starting from the same point") + + const backwardArray = diagram.edges.flatMap(edge => edge.segments.map(segment => [posToStr(edge, scale(segment.to)), scale(segment.from)])) as [string, Position][]; + this.reverseExtraLine = new Map(backwardArray); + console.assert(backwardArray.length == this.reverseExtraLine.size, "Some edge diagram have been lost because 2 segments are ending at the same point") + + + const subpathwayIds = new Set(diagram.shadows.map((shadow) => shadow.reactomeId)) + + const eventIdToSubPathwayId = new Map(graph.subpathways?.flatMap(subpathway => subpathway.events + .map(event => [event, subpathway.dbId]) + .filter(entry => subpathwayIds.has(entry[1]))) as [number, number][] || []) + + const subpathwayIdToEventIds = new Map(graph.subpathways?.map(subpathway => [subpathway.dbId, subpathway.events])); + const subpathwayStIdToEventIds = new Map(graph.subpathways?.map(subpathway => [subpathway.stId, subpathway.events])); + // create a node id - graph node mapping + const dbIdToGraphNode = new Map(graph.nodes.map(node => ([node.dbId, node]))) + const mappingList: [number, Graph.Node][] = graph.nodes.flatMap(node => { + if (node.children && node.children.length === 1 && node.diagramIds?.length !== 1) { // Consider homomer complex like their constituents for interactors + return node.diagramIds?.map(id => [id, dbIdToGraphNode.get(node.children[0])]) + .filter(entry => entry[1] !== undefined) as [number, Graph.Node][] + } else { + return node.diagramIds?.map(id => [id, node]) as [number, Graph.Node][] + } + }).filter(entry => entry !== undefined); + + const idToGraphNodes = new Map([...mappingList]); + const idToGraphEdges = new Map(graph.edges.map(edge => [edge.dbId, edge])); + + const getLeaves = (node: Graph.Node, leaves: Set) => { + if (node.children && node.children.length > 0) + node.children.forEach(child => getLeaves(dbIdToGraphNode.get(child)!, leaves)) + else + leaves.add(node); + } + + idToGraphNodes.forEach(node => { + if (node.children?.length > 0) { + let leaves = new Set(); + getLeaves(node, leaves); + node.leaves = [...leaves]; + } + }) + + const dbIdToGraphEdge = new Map(graph.edges.map(edge => ([edge.dbId, edge]))) + + const hasFadeOut = diagram.nodes.some(node => node.isFadeOut); + const normalNodes = diagram.nodes.filter(node => node.isFadeOut); + const specialNodes = diagram.nodes.filter(node => !node.isFadeOut); + const posToNormalNode = new Map(normalNodes.map(node => [pointToStr(node.position), node])); + const posToSpecialNode = new Map(specialNodes.map(node => [pointToStr(node.position), node])); + + const normalEdges = diagram.edges.filter(edge => edge.isFadeOut); + const specialEdges = diagram.edges.filter(edge => !edge.isFadeOut); + const posToNormalEdge = new Map(normalEdges.map(edge => [pointToStr(edge.position), edge])); + const posToSpecialEdge = new Map(specialEdges.map(edge => [pointToStr(edge.position), edge])); + + //compartment nodes + const compartmentNodes: cytoscape.NodeDefinition[] = diagram?.compartments.flatMap(item => { + const propToRects = (prop: Prop): { [p: string]: number } => ({ + left: scale(prop.x), + top: scale(prop.y), + right: scale(prop.x + prop.width), + bottom: scale(prop.x + prop.height), + }) + + let innerCR = 10; + let outerCR + if (item.insets) { + const rects = [propToRects(item.prop), propToRects(item.insets)] + outerCR = Object.keys(rects[0]).reduce((smallest, key) => Math.min(smallest, Math.abs(rects[0][key] - rects[1][key])), Number.MAX_SAFE_INTEGER); + outerCR = innerCR + Math.min(outerCR, 100) + } + + const layers: cytoscape.NodeDefinition[] = [ + { + data: { + id: item.id + '-outer', + displayName: item.displayName, + textX: scale(item.textPosition.x - (item.prop.x + item.prop.width)) + this.COMPARTMENT_SHIFT, + textY: scale(item.textPosition.y - (item.prop.y + item.prop.height)) + this.COMPARTMENT_SHIFT, + width: scale(item.prop.width), + height: scale(item.prop.height), + radius: outerCR + }, + classes: ['Compartment', 'outer'], + position: scale(item.position), + selectable: false, + } + ]; + + if (item.insets) { + layers.push({ + data: { + id: item.id + '-inner', + width: scale(item.insets.width), + height: scale(item.insets.height), + radius: innerCR + }, + classes: ['Compartment', 'inner'], + position: scale({x: item.insets.x + item.insets.width / 2, y: item.insets.y + item.insets.height / 2}), + selectable: false, }) + } + return layers; + }); - const dbIdToGraphEdge = new Map(graph.edges.map(edge => ([edge.dbId, edge]))) - - const hasFadeOut = diagram.nodes.some(node => node.isFadeOut); - const normalNodes = diagram.nodes.filter(node => node.isFadeOut); - const specialNodes = diagram.nodes.filter(node => !node.isFadeOut); - const posToNormalNode = new Map(normalNodes.map(node => [pointToStr(node.position), node])); - const posToSpecialNode = new Map(specialNodes.map(node => [pointToStr(node.position), node])); - - const normalEdges = diagram.edges.filter(edge => edge.isFadeOut); - const specialEdges = diagram.edges.filter(edge => !edge.isFadeOut); - const posToNormalEdge = new Map(normalEdges.map(edge => [pointToStr(edge.position), edge])); - const posToSpecialEdge = new Map(specialEdges.map(edge => [pointToStr(edge.position), edge])); - - //compartment nodes - const compartmentNodes: cytoscape.NodeDefinition[] = diagram?.compartments.flatMap(item => { - const propToRects = (prop: Prop): { [p: string]: number } => ({ - left: scale(prop.x), - top: scale(prop.y), - right: scale(prop.x + prop.width), - bottom: scale(prop.x + prop.height), - }) - - let innerCR = 10; - let outerCR - if (item.insets) { - const rects = [propToRects(item.prop), propToRects(item.insets)] - outerCR = Object.keys(rects[0]).reduce((smallest, key) => Math.min(smallest, Math.abs(rects[0][key] - rects[1][key])), Number.MAX_SAFE_INTEGER); - outerCR = innerCR + Math.min(outerCR, 100) - } + const replacementMap = new Map(); - const layers: cytoscape.NodeDefinition[] = [ - { - data: { - id: item.id + '-outer', - displayName: item.displayName, - textX: scale(item.textPosition.x - (item.prop.x + item.prop.width)) + this.COMPARTMENT_SHIFT, - textY: scale(item.textPosition.y - (item.prop.y + item.prop.height)) + this.COMPARTMENT_SHIFT, - width: scale(item.prop.width), - height: scale(item.prop.height), - radius: outerCR - }, - classes: ['Compartment', 'outer'], - position: scale(item.position), - selectable: false, - } - ]; + //reaction nodes + const reactionNodes: cytoscape.NodeDefinition[] = diagram?.edges.map(item => { + let replacement, replacedBy; + if (item.isFadeOut) { + replacedBy = posToSpecialEdge.get(pointToStr(item.position))?.id.toString() || specialEdges.find(edge => squaredDist(scale(edge.position), scale(item.position)) < 5 ** 2)?.id.toString(); + if (replacedBy) { + replacementMap.set(item.id.toString(), replacedBy) + replacementMap.set(replacedBy, item.id.toString()) + } + } + if (!item.isFadeOut) { + replacement = posToNormalEdge.get(pointToStr(item.position))?.id.toString() || normalEdges.find(edge => squaredDist(scale(edge.position), scale(item.position)) < 5 ** 2)?.id.toString(); + } + let subpathways = [...subpathwayStIdToEventIds.entries()].flatMap(([subpathwayId, events]) => events.includes(item.reactomeId) ? [subpathwayId] : []); - if (item.insets) { - layers.push({ - data: { - id: item.id + '-inner', - width: scale(item.insets.width), - height: scale(item.insets.height), - radius: innerCR - }, - classes: ['Compartment', 'inner'], - position: scale({x: item.insets.x + item.insets.width / 2, y: item.insets.y + item.insets.height / 2}), - selectable: false, - }) - } - return layers; - }); - - const replacementMap = new Map(); - - //reaction nodes - const reactionNodes: cytoscape.NodeDefinition[] = diagram?.edges.map(item => { - let replacement, replacedBy; - if (item.isFadeOut) { - replacedBy = posToSpecialEdge.get(pointToStr(item.position))?.id.toString() || specialEdges.find(edge => squaredDist(scale(edge.position), scale(item.position)) < 5 ** 2)?.id.toString(); - if (replacedBy) { - replacementMap.set(item.id.toString(), replacedBy) - replacementMap.set(replacedBy, item.id.toString()) - } - } - if (!item.isFadeOut) { - replacement = posToNormalEdge.get(pointToStr(item.position))?.id.toString() || normalEdges.find(edge => squaredDist(scale(edge.position), scale(item.position)) < 5 ** 2)?.id.toString(); - } - let subpathways = [...subpathwayStIdToEventIds.entries()].flatMap(([subpathwayId, events]) => events.includes(item.reactomeId) ? [subpathwayId] : []); + return ({ + data: { + id: item.id + '', + // displayName: item.displayName, + inputs: item.inputs, + output: item.outputs, + isFadeOut: item.isFadeOut, + isBackground: item.isFadeOut, + reactomeId: item.reactomeId, + reactionId: item.id, + graph: idToGraphEdges.get(item.reactomeId), + subpathways: subpathways, + replacement, replacedBy + }, + classes: this.reactionTypeMap.get(item.reactionType), + position: scale(item.position) + }); + }); - return ({ - data: { - id: item.id + '', - // displayName: item.displayName, - inputs: item.inputs, - output: item.outputs, - isFadeOut: item.isFadeOut, - isBackground: item.isFadeOut, - reactomeId: item.reactomeId, - reactionId: item.id, - graph: idToGraphEdges.get(item.reactomeId), - subpathways: subpathways, - replacement, replacedBy - }, - classes: this.reactionTypeMap.get(item.reactionType), - position: scale(item.position) - }); - }); - - - //entity nodes - const entityNodes: cytoscape.NodeDefinition[] = diagram?.nodes.flatMap(item => { - let classes = [...this.nodeTypeMap.get(item.renderableClass) || item.renderableClass.toLowerCase()]; - let unitId = undefined; - if (item.schemaClass === SchemaClasses.POLYMER) { - const polymerGraphNode = dbIdToGraphNode.get(item.reactomeId)!; - const unitGraph = dbIdToGraphNode.get(polymerGraphNode.children[0])!; - const unitClass = this.nodeTypeMap.get(unitGraph.schemaClass) || this.nodeTypeMap.get(this.schemaClassToNodeTypeMap.get(unitGraph.schemaClass === 'EntityWithAccessionedSequence' ? unitGraph.referenceType : unitGraph.schemaClass)!) || ['GenomeEncodedEntity', 'PhysicalEntity']; - classes = [SchemaClasses.POLYMER, ...unitClass] - unitId = unitGraph.identifier; - } - let replacedBy: string | undefined; - let replacement: string | undefined; - if (item.isDisease) classes.push('disease'); - if (item.isCrossed) classes.push('crossed'); - if (item.trivial) classes.push('trivial'); - if (item.needDashedBorder) classes.push('loss-of-function'); - if (item.isFadeOut) { - replacedBy = posToSpecialNode.get(pointToStr(item.position))?.id.toString() - if (!replacedBy) { - replacedBy = specialNodes.find(node => overlapLimited(item, node, 0.8))?.id.toString(); + + //entity nodes + const entityNodes: cytoscape.NodeDefinition[] = diagram?.nodes.flatMap(item => { + let classes = [...this.nodeTypeMap.get(item.renderableClass) || item.renderableClass.toLowerCase()]; + let unitId = undefined; + if (item.schemaClass === SchemaClasses.POLYMER) { + const polymerGraphNode = dbIdToGraphNode.get(item.reactomeId)!; + const unitGraph = dbIdToGraphNode.get(polymerGraphNode.children[0])!; + const unitClass = this.nodeTypeMap.get(unitGraph.schemaClass) || this.nodeTypeMap.get(this.schemaClassToNodeTypeMap.get(unitGraph.schemaClass === 'EntityWithAccessionedSequence' ? unitGraph.referenceType : unitGraph.schemaClass)!) || ['GenomeEncodedEntity', 'PhysicalEntity']; + classes = [SchemaClasses.POLYMER, ...unitClass] + unitId = unitGraph.identifier; + } + let replacedBy: string | undefined; + let replacement: string | undefined; + if (item.isDisease) classes.push('disease'); + if (item.isCrossed) classes.push('crossed'); + if (item.trivial) classes.push('trivial'); + if (item.needDashedBorder) classes.push('loss-of-function'); + if (item.isFadeOut) { + replacedBy = posToSpecialNode.get(pointToStr(item.position))?.id.toString() + if (!replacedBy) { + replacedBy = specialNodes.find(node => overlapLimited(item, node, 0.8))?.id.toString(); + } + if (replacedBy) { + replacementMap.set(item.id.toString(), replacedBy) + replacementMap.set(replacedBy, item.id.toString()) + } + } + if (!item.isFadeOut) replacement = posToNormalNode.get(pointToStr(item.position))?.id.toString() //|| normalNodes.find(node => overlap(item, node))?.id.toString(); + if (classes.some(clazz => clazz === 'RNA')) item.prop.height -= 10; + if (classes.some(clazz => clazz === 'Cell')) item.prop.height /= 2; + const isBackground = item.isFadeOut || classes.some(clazz => clazz === 'Pathway') || item.connectors.some(connector => connector.isFadeOut); + item.isBackground = isBackground; + let html = undefined; + let width = scale(item.prop.width); + let height = scale(item.prop.height); + const graphData = idToGraphNodes.get(item.id); + if (!graphData) console.error("Missing graph data for node: ", item.id, ". Potential reason could be a wrong normal pathway for a disease") + let preferredId = unitId || graphData?.identifier; + let chebiStructure = preferredId ? chebiMapping.get(preferredId) : undefined + if (classes.some(clazz => clazz === 'Protein')) { + html = this.getStructureVideoHtml({...item, type: 'Protein'}, width, height, preferredId); + } + if (isBackground && !item.isFadeOut) { + replacementMap.set(item.id.toString(), item.id.toString()) + } + const isFadeOut = !item.isCrossed && item.isFadeOut; + const nodes: cytoscape.NodeDefinition[] = [ + { + data: { + id: item.id + '', + reactomeId: item.reactomeId, + displayName: item.displayName.replace(/([/,:;-])/g, "$1\u200b"), + height: height, + width: width, + graph: graphData, + acc: preferredId, + html, + chebiStructure, + isFadeOut, + isBackground, + replacement, + replacedBy + }, + classes: classes, + position: scale(item.position) + } + ]; + if (item.nodeAttachments) { + nodes.push(...item.nodeAttachments.map(ptm => ({ + data: { + id: item.id + '-' + ptm.reactomeId, + reactomeId: ptm.reactomeId, + nodeId: item.id, + nodeReactomeId: item.reactomeId, + displayName: ptm.label, + height: scale(ptm.shape.b.y - ptm.shape.a.y), + width: scale(ptm.shape.b.x - ptm.shape.a.x), + isFadeOut, + isBackground, + replacement, + replacedBy + }, + classes: "Modification", + position: scale(ptm.shape.centre ?? {x: (ptm.shape.a.x + ptm.shape.b.x) / 2, y: (ptm.shape.a.y + ptm.shape.b.y) / 2}) + }))) + } + return nodes + }); + + //sub pathways + const shadowNodes: cytoscape.NodeDefinition[] = diagram?.shadows.map(item => { + return { + data: { + id: item.id + '', + displayName: item.displayName, + height: scale(item.prop.height), + width: scale(item.prop.width), + reactomeId: item.reactomeId, + isFadeOut: item.isFadeOut, + replacedBy: item.isFadeOut, + triggerPosition: scale(item.maxX) + }, + classes: ['Shadow'], + position: closestToAverage(subpathwayIdToEventIds.get(item.reactomeId)!.map(reactionId => reactomeIdToEdge.get(reactionId)!).map(edge => scale(edge!.position))) + } + }); + + avoidOverlap(shadowNodes); + + const T = 4; + const ARROW_MULT = 1.5; + const EDGE_MARGIN = 6; + const REACTION_RADIUS = 3 * T; + const MIN_DIST = EDGE_MARGIN; + + + /** + * Edges: iterate nodes connectors to get all edges information based on the connector type. + * + */ + const edges: cytoscape.EdgeDefinition[] = + diagram.nodes.flatMap(node => { + return node.connectors.map(connector => { + const reaction = idToEdges.get(connector.edgeId)!; + + const reactionP = scale(reaction.position); + const nodeP = scale(node.position); + + const [source, target] = connector.type !== 'OUTPUT' ? + [node, reaction] : + [reaction, node]; + + const sourceP = scale(source.position); + const targetP = scale(target.position); + + let points = connector.segments + .flatMap((segment, i) => i === 0 ? [segment.from, segment.to] : [segment.to]) + .map(pos => scale(pos)); + if (connector.type === 'OUTPUT') points.reverse(); + if (points.length === 0) points.push(reactionP); + + this.addEdgeInfo(reaction, points, 'backward', sourceP); + this.addEdgeInfo(reaction, points, 'forward', targetP); + + let [from, to] = [points.shift()!, points.pop()!] + from = from ?? nodeP; // Quick fix to avoid problem with reaction without visible outputs like R-HSA-2424252 in R-HSA-1474244 + to = to ?? reactionP; // Quick fix to avoid problem with reaction without visible outputs like R-HSA-2424252 in R-HSA-1474244 + if (connector.type === 'CATALYST' && connector.endShape) { + to = scale(connector.endShape.centre || connector.endShape.c); } - if (replacedBy) { - replacementMap.set(item.id.toString(), replacedBy) - replacementMap.set(replacedBy, item.id.toString()) + + // points = addRoundness(from, to, points); + const relatives = this.absoluteToRelative(from, to, points); + + const classes = [...this.edgeTypeMap.get(connector.type)!]; + if (reaction.isDisease) classes.push('disease'); + if (node.trivial) classes.push('trivial'); + if (eventIdToSubPathwayId.has(reaction.reactomeId)) classes.push('shadow'); + + let subpathways = [...subpathwayStIdToEventIds.entries()].flatMap(([subpathwayId, events]) => events.includes(reaction.reactomeId) ? [subpathwayId] : []); + + let d = dist(from, to); + if (equal(from, reactionP) || equal(to, reactionP)) d -= REACTION_RADIUS; + if (classes.includes('positive-regulation') || classes.includes('catalysis') || classes.includes('production')) d -= ARROW_MULT * T; + // console.assert(d > MIN_DIST, `The edge between reaction: R-HSA-${reaction.reactomeId} and entity: R-HSA-${node.reactomeId} in pathway ${id} has a visible length of ${d} which is shorter than ${MIN_DIST}`) + console.assert(d > MIN_DIST, `${id}\t${diagram.displayName}\t${hasFadeOut}\tR-HSA-${reaction.reactomeId}\tR-HSA-${node.reactomeId}\thttps://release.reactome.org/PathwayBrowser/#/${id}&SEL=R-HSA-${reaction.reactomeId}&FLG=R-HSA-${node.reactomeId}\thttps://reactome-pwp.github.io/PathwayBrowser/${id}?select=${reaction.reactomeId}&flag=${node.reactomeId}`) + + let replacement, replacedBy; + if (connector.isFadeOut) { + // First case: same node is used both special and normal context + // replacedBy = node.connectors.find(otherConnector => otherConnector !== connector && !otherConnector.isFadeOut && samePoint(idToEdges.get(otherConnector.edgeId)!.position, reaction.position))?.edgeId; + // Second case: different nodes are used between special and normal context + // replacedBy = replacedBy || (posToSpecialNode.get(pointToStr(node.position)) && posToSpecialEdge.get(pointToStr(reaction.position)))?.id; + + replacedBy = replacementMap.get(node.id.toString()) && replacementMap.get(reaction.id.toString()) } - } - if (!item.isFadeOut) replacement = posToNormalNode.get(pointToStr(item.position))?.id.toString() //|| normalNodes.find(node => overlap(item, node))?.id.toString(); - if (classes.some(clazz => clazz === 'RNA')) item.prop.height -= 10; - if (classes.some(clazz => clazz === 'Cell')) item.prop.height /= 2; - const isBackground = item.isFadeOut || classes.some(clazz => clazz === 'Pathway') || item.connectors.some(connector => connector.isFadeOut); - item.isBackground = isBackground; - let html = undefined; - let width = scale(item.prop.width); - let height = scale(item.prop.height); - const graphData = idToGraphNodes.get(item.id); - if (!graphData) console.error("Missing graph data for node: ", item.id, ". Potential reason could be a wrong normal pathway for a disease") - let preferredId = unitId || graphData?.identifier; - let chebiStructure = preferredId ? chebiMapping.get(preferredId) : undefined - if (classes.some(clazz => clazz === 'Protein')) { - html = this.getStructureVideoHtml({...item, type: 'Protein'}, width, height, preferredId); - } - if (isBackground && !item.isFadeOut) { - replacementMap.set(item.id.toString(), item.id.toString()) - } - const isFadeOut = !item.isCrossed && item.isFadeOut; - const nodes: cytoscape.NodeDefinition[] = [ - { - data: { - id: item.id + '', - reactomeId: item.reactomeId, - displayName: item.displayName.replace(/([/,:;-])/g, "$1\u200b"), - height: height, - width: width, - graph: graphData, - acc: preferredId, - html, - chebiStructure, - isFadeOut, - isBackground, - replacement, - replacedBy - }, - classes: classes, - position: scale(item.position) + if (!connector.isFadeOut) { + // First case: same node is used both special and normal context + replacement = node.connectors.find(otherConnector => otherConnector !== connector && otherConnector.isFadeOut && samePoint(idToEdges.get(otherConnector.edgeId)!.position, reaction.position))?.edgeId; + // console.log("Reaction edge", replacement) + + // Second case: different nodes are used between special and normal context + replacement = replacement || (posToNormalNode.get(pointToStr(node.position)) && posToNormalEdge.get(pointToStr(reaction.position)))?.id; + // console.log("Reaction edge", replacement) + } - ]; - if (item.nodeAttachments) { - nodes.push(...item.nodeAttachments.map(ptm => ({ + const edge: cytoscape.EdgeDefinition = { data: { - id: item.id + '-' + ptm.reactomeId, - reactomeId: ptm.reactomeId, - nodeId: item.id, - nodeReactomeId: item.reactomeId, - displayName: ptm.label, - height: scale(ptm.shape.b.y - ptm.shape.a.y), - width: scale(ptm.shape.b.x - ptm.shape.a.x), - isFadeOut, - isBackground, - replacement, - replacedBy + id: this.getEdgeId(source, connector, target, edgeIds), + graph: dbIdToGraphEdge.get(reaction.reactomeId), + source: source.id + '', + target: target.id + '', + stoichiometry: connector.stoichiometry.value, + weights: relatives.weights.join(" "), + distances: relatives.distances.join(" "), + sourceEndpoint: this.endpoint(sourceP, from), + targetEndpoint: this.endpoint(targetP, to), + pathway: eventIdToSubPathwayId.get(reaction.reactomeId), + reactomeId: reaction.reactomeId, + reactionId: reaction.id, + isFadeOut: reaction.isFadeOut, + isBackground: reaction.isFadeOut, + subpathways, + replacedBy, replacement }, - classes: "Modification", - position: scale(ptm.shape.centre) - }))) - } - return nodes - }); + classes: classes + }; + return edge + }); + } + ); + + const linkEdges: cytoscape.EdgeDefinition[] = diagram.links + ?.filter(link => !link.renderableClass.includes('EntitySet') || link.inputs[0].id !== link.outputs[0].id) + ?.map(link => { + const source = idToNodes.get(link.inputs[0].id)!; + const target = idToNodes.get(link.outputs[0].id)!; + + const sourceP = scale(source.position); + const targetP = scale(target.position); + + let points = link.segments + .flatMap((segment, i) => i === 0 ? [segment.from, segment.to] : [segment.to]) + .map(pos => scale(pos)); + + let [from, to] = [points.shift()!, points.pop()!] + from = from ?? sourceP; // Quick fix to avoid problem with reaction without visible outputs like R-HSA-2424252 in R-HSA-1474244 + to = to ?? targetP; // Quick fix to avoid problem with reaction without visible outputs like R-HSA-2424252 in R-HSA-1474244 + + // points = addRoundness(from, to, points); + const relatives = this.absoluteToRelative(from, to, points); + + const classes = [...this.linkClassMap.get(link.renderableClass)!]; + if (link.isDisease) classes.push('disease'); + const isBackground = link.isFadeOut || + idToNodes.get(link.inputs[0].id)?.isBackground && + idToNodes.get(link.outputs[0].id)?.isBackground; - //sub pathways - const shadowNodes: cytoscape.NodeDefinition[] = diagram?.shadows.map(item => { return { data: { - id: item.id + '', - displayName: item.displayName, - height: scale(item.prop.height), - width: scale(item.prop.width), - reactomeId: item.reactomeId, - isFadeOut: item.isFadeOut, - replacedBy: item.isFadeOut, - triggerPosition: scale(item.maxX) + id: link.id + '', + source: link.inputs[0].id + '', + target: link.outputs[0].id + '', + weights: relatives.weights.join(" "), + distances: relatives.distances.join(" "), + sourceEndpoint: this.endpoint(sourceP, from), + targetEndpoint: this.endpoint(targetP, to), + isFadeOut: link.isFadeOut, + isBackground: isBackground }, - classes: ['Shadow'], - position: closestToAverage(subpathwayIdToEventIds.get(item.reactomeId)!.map(reactionId => reactomeIdToEdge.get(reactionId)!).map(edge => scale(edge!.position))) + classes: classes, + selectable: false } - }); - - avoidOverlap(shadowNodes); - - const T = 4; - const ARROW_MULT = 1.5; - const EDGE_MARGIN = 6; - const REACTION_RADIUS = 3 * T; - const MIN_DIST = EDGE_MARGIN; - - - /** - * Edges: iterate nodes connectors to get all edges information based on the connector type. - * - */ - const edges: cytoscape.EdgeDefinition[] = - diagram.nodes.flatMap(node => { - return node.connectors.map(connector => { - const reaction = idToEdges.get(connector.edgeId)!; - - const reactionP = scale(reaction.position); - const nodeP = scale(node.position); - - const [source, target] = connector.type !== 'OUTPUT' ? - [node, reaction] : - [reaction, node]; - - const sourceP = scale(source.position); - const targetP = scale(target.position); - - let points = connector.segments - .flatMap((segment, i) => i === 0 ? [segment.from, segment.to] : [segment.to]) - .map(pos => scale(pos)); - if (connector.type === 'OUTPUT') points.reverse(); - if (points.length === 0) points.push(reactionP); - - this.addEdgeInfo(reaction, points, 'backward', sourceP); - this.addEdgeInfo(reaction, points, 'forward', targetP); - - let [from, to] = [points.shift()!, points.pop()!] - from = from ?? nodeP; // Quick fix to avoid problem with reaction without visible outputs like R-HSA-2424252 in R-HSA-1474244 - to = to ?? reactionP; // Quick fix to avoid problem with reaction without visible outputs like R-HSA-2424252 in R-HSA-1474244 - if (connector.type === 'CATALYST') { - to = scale(connector.endShape.centre); - } - - // points = addRoundness(from, to, points); - const relatives = this.absoluteToRelative(from, to, points); - - const classes = [...this.edgeTypeMap.get(connector.type)!]; - if (reaction.isDisease) classes.push('disease'); - if (node.trivial) classes.push('trivial'); - if (eventIdToSubPathwayId.has(reaction.reactomeId)) classes.push('shadow'); - - let subpathways = [...subpathwayStIdToEventIds.entries()].flatMap(([subpathwayId, events]) => events.includes(reaction.reactomeId) ? [subpathwayId] : []); - - let d = dist(from, to); - if (equal(from, reactionP) || equal(to, reactionP)) d -= REACTION_RADIUS; - if (classes.includes('positive-regulation') || classes.includes('catalysis') || classes.includes('production')) d -= ARROW_MULT * T; - // console.assert(d > MIN_DIST, `The edge between reaction: R-HSA-${reaction.reactomeId} and entity: R-HSA-${node.reactomeId} in pathway ${id} has a visible length of ${d} which is shorter than ${MIN_DIST}`) - console.assert(d > MIN_DIST, `${id}\t${diagram.displayName}\t${hasFadeOut}\tR-HSA-${reaction.reactomeId}\tR-HSA-${node.reactomeId}\thttps://release.reactome.org/PathwayBrowser/#/${id}&SEL=R-HSA-${reaction.reactomeId}&FLG=R-HSA-${node.reactomeId}\thttps://reactome-pwp.github.io/PathwayBrowser/${id}?select=${reaction.reactomeId}&flag=${node.reactomeId}`) - - let replacement, replacedBy; - if (connector.isFadeOut) { - // First case: same node is used both special and normal context - // replacedBy = node.connectors.find(otherConnector => otherConnector !== connector && !otherConnector.isFadeOut && samePoint(idToEdges.get(otherConnector.edgeId)!.position, reaction.position))?.edgeId; - // Second case: different nodes are used between special and normal context - // replacedBy = replacedBy || (posToSpecialNode.get(pointToStr(node.position)) && posToSpecialEdge.get(pointToStr(reaction.position)))?.id; - - replacedBy = replacementMap.get(node.id.toString()) && replacementMap.get(reaction.id.toString()) - } - if (!connector.isFadeOut) { - // First case: same node is used both special and normal context - replacement = node.connectors.find(otherConnector => otherConnector !== connector && otherConnector.isFadeOut && samePoint(idToEdges.get(otherConnector.edgeId)!.position, reaction.position))?.edgeId; - // console.log("Reaction edge", replacement) - - // Second case: different nodes are used between special and normal context - replacement = replacement || (posToNormalNode.get(pointToStr(node.position)) && posToNormalEdge.get(pointToStr(reaction.position)))?.id; - // console.log("Reaction edge", replacement) - - } - const edge: cytoscape.EdgeDefinition = { - data: { - id: this.getEdgeId(source, connector, target, edgeIds), - graph: dbIdToGraphEdge.get(reaction.reactomeId), - source: source.id + '', - target: target.id + '', - stoichiometry: connector.stoichiometry.value, - weights: relatives.weights.join(" "), - distances: relatives.distances.join(" "), - sourceEndpoint: this.endpoint(sourceP, from), - targetEndpoint: this.endpoint(targetP, to), - pathway: eventIdToSubPathwayId.get(reaction.reactomeId), - reactomeId: reaction.reactomeId, - reactionId: reaction.id, - isFadeOut: reaction.isFadeOut, - isBackground: reaction.isFadeOut, - subpathways, - replacedBy, replacement - }, - classes: classes - }; - return edge - }); - } - ); - - const linkEdges: cytoscape.EdgeDefinition[] = diagram.links - ?.filter(link => !link.renderableClass.includes('EntitySet') || link.inputs[0].id !== link.outputs[0].id) - ?.map(link => { - const source = idToNodes.get(link.inputs[0].id)!; - const target = idToNodes.get(link.outputs[0].id)!; - - const sourceP = scale(source.position); - const targetP = scale(target.position); - - let points = link.segments - .flatMap((segment, i) => i === 0 ? [segment.from, segment.to] : [segment.to]) - .map(pos => scale(pos)); - - let [from, to] = [points.shift()!, points.pop()!] - from = from ?? sourceP; // Quick fix to avoid problem with reaction without visible outputs like R-HSA-2424252 in R-HSA-1474244 - to = to ?? targetP; // Quick fix to avoid problem with reaction without visible outputs like R-HSA-2424252 in R-HSA-1474244 - - // points = addRoundness(from, to, points); - const relatives = this.absoluteToRelative(from, to, points); - - const classes = [...this.linkClassMap.get(link.renderableClass)!]; - if (link.isDisease) classes.push('disease'); - const isBackground = link.isFadeOut || - idToNodes.get(link.inputs[0].id)?.isBackground && - idToNodes.get(link.outputs[0].id)?.isBackground; - - return { - data: { - id: link.id + '', - source: link.inputs[0].id + '', - target: link.outputs[0].id + '', - weights: relatives.weights.join(" "), - distances: relatives.distances.join(" "), - sourceEndpoint: this.endpoint(sourceP, from), - targetEndpoint: this.endpoint(targetP, to), - isFadeOut: link.isFadeOut, - isBackground: isBackground - }, - classes: classes, - selectable: false - } - } - ) - - console.log('All data created') - return { - nodes: [...compartmentNodes, ...reactionNodes, ...entityNodes, ...shadowNodes], - edges: [...edges, ...linkEdges] - }; - }), - tap((output) => console.log('Output:', output)), - ) + } + ) + console.log('All data created') + return { + nodes: [...compartmentNodes, ...reactionNodes, ...entityNodes, ...shadowNodes], + edges: [...edges, ...linkEdges] + }; } getStructureVideoHtml(item: { @@ -705,18 +712,31 @@ export class DiagramService { private addEdgeInfo(edge: Edge, points: Position[], direction: 'forward' | 'backward', stop: Position) { const stopPos = posToStr(edge, stop); + const visited = new Set(); if (direction === 'forward') { - const map = this.extraLine; let pos = posToStr(edge, points.at(-1)!) - while (map.has(pos) && pos !== stopPos) { - points.push(map.get(pos)!) + while (pos !== stopPos && !visited.has(pos)) { + visited.add(pos); + if (this.extraLine.has(pos)) { + points.push(this.extraLine.get(pos)!) + } else if (this.reverseExtraLine.has(pos)) { + points.push(this.reverseExtraLine.get(pos)!) + } else { + break; + } pos = posToStr(edge, points.at(-1)!) } } else { - const map = this.reverseExtraLine; let pos = posToStr(edge, points.at(0)!) - while (map.has(pos) && pos !== stopPos) { - points.unshift(map.get(pos)!) + while (pos !== stopPos && !visited.has(pos)) { + visited.add(pos); + if (this.reverseExtraLine.has(pos)) { + points.unshift(this.reverseExtraLine.get(pos)!) + } else if (this.extraLine.has(pos)) { + points.unshift(this.extraLine.get(pos)!) + } else { + break; + } pos = posToStr(edge, points.at(0)!) } } diff --git a/projects/pathway-browser/src/app/services/species.service.ts b/projects/pathway-browser/src/app/services/species.service.ts index de142ec..440b062 100644 --- a/projects/pathway-browser/src/app/services/species.service.ts +++ b/projects/pathway-browser/src/app/services/species.service.ts @@ -121,7 +121,7 @@ export class SpeciesService { else params[key] = JSON.parse(newValue); } - this.router.navigate([pathwayId].filter(isDefined), { + this.state.navigateTo(pathwayId ?? null, { queryParams: params, preserveFragment: true }); diff --git a/projects/pathway-browser/src/app/services/url-state.service.ts b/projects/pathway-browser/src/app/services/url-state.service.ts index 16d3460..4c3f036 100644 --- a/projects/pathway-browser/src/app/services/url-state.service.ts +++ b/projects/pathway-browser/src/app/services/url-state.service.ts @@ -1,5 +1,5 @@ import {effect, inject, Injectable, signal, WritableSignal} from '@angular/core'; -import {ActivatedRoute, NavigationEnd, Params, Router} from "@angular/router"; +import {ActivatedRoute, NavigationEnd, NavigationExtras, Params, Router} from "@angular/router"; import {catchError, filter, firstValueFrom, map, of, switchMap} from "rxjs"; import {isArray, isNumber} from "lodash"; import {HttpClient} from "@angular/common/http"; @@ -112,7 +112,11 @@ export class UrlStateService implements State { constructor() { this.router.events.pipe( filter(event => event instanceof NavigationEnd), - switchMap(() => this.router.routerState.root.firstChild?.params || of()), + switchMap(() => { + let route = this.router.routerState.root; + while (route.firstChild) route = route.firstChild; + return route.params; + }), map(params => params['pathwayId']) ).subscribe((id) => { this.pathwayId.set(id) @@ -127,7 +131,7 @@ export class UrlStateService implements State { return; } - this.router.navigate(this.pathwayId() ? [this.pathwayId()] : [], { + this.navigateTo(this.pathwayId() ?? null, { queryParamsHandling: 'preserve', preserveFragment: true }); @@ -153,7 +157,7 @@ export class UrlStateService implements State { } } - this.router.navigate(id ? [id] : [], { + this.navigateTo(id ?? null, { queryParamsHandling: 'merge', fragment: fragment.replace(FRAGMENT_PATTERN, ''), preserveFragment: false, @@ -215,7 +219,21 @@ export class UrlStateService implements State { console.log('In content or search route, not navigating on state change'); return; } - this.router.navigate(this.pathwayId() ? [this.pathwayId()] : [], {queryParams, preserveFragment: true}); + this.navigateTo(this.pathwayId() ?? null, {queryParams, preserveFragment: true}); + }); + } + + /** + * Navigate to a pathway within the PathwayBrowser route context. + * Resolves the correct base path whether running standalone or inside the umbrella app. + */ + navigateTo(pathwayId: string | null, extras: NavigationExtras = {}): Promise { + let route = this.router.routerState.root; + while (route.firstChild) route = route.firstChild; + const segments = pathwayId ? [pathwayId] : []; + return this.router.navigate(segments, { + relativeTo: route.parent, + ...extras }); } diff --git a/projects/website-angular/src/app/app.routes.ts b/projects/website-angular/src/app/app.routes.ts index 42b5bf2..517b140 100644 --- a/projects/website-angular/src/app/app.routes.ts +++ b/projects/website-angular/src/app/app.routes.ts @@ -36,6 +36,11 @@ export const routes: Routes = [ { path: 'content/schema', loadComponent: () => import('./content/schema/schema.component').then(m => m.SchemaComponent), pathMatch: 'full' }, { path: 'content/schema/:className', loadComponent: () => import('./content/schema/schema.component').then(m => m.SchemaComponent), pathMatch: 'full' }, + //Detail Pages + { path: 'content/detail/interactor/:acc', loadComponent: () => import('./content/detail/interactor-detail/interactor-detail.component').then(m => m.InteractorDetailComponent) }, + { path: 'content/detail/icon/:id', loadComponent: () => import('./content/detail/icon-detail/icon-detail.component').then(m => m.IconDetailComponent) }, + { path: 'content/detail/:id', loadComponent: () => import('./content/detail/detail.component').then(m => m.DetailComponent) }, + //Search Pages { path: 'content/query', loadComponent: () => import('./search/search.component').then(m => m.SearchComponent) }, { path: 'tools/site-search', loadComponent: () => import('./site-search/site-search.component').then(m => m.SiteSearchComponent) }, diff --git a/projects/website-angular/src/app/content/detail/detail.component.html b/projects/website-angular/src/app/content/detail/detail.component.html new file mode 100644 index 0000000..a8dc5ed --- /dev/null +++ b/projects/website-angular/src/app/content/detail/detail.component.html @@ -0,0 +1,16 @@ + + @if (loading()) { +
+ +
+ } @else if (error()) { +
+

Entity not found

+

The requested entity could not be found.

+
+ } @else if (obj()) { +
+ +
+ } +
diff --git a/projects/website-angular/src/app/content/detail/detail.component.scss b/projects/website-angular/src/app/content/detail/detail.component.scss new file mode 100644 index 0000000..6d8f267 --- /dev/null +++ b/projects/website-angular/src/app/content/detail/detail.component.scss @@ -0,0 +1,63 @@ +:host { + display: block; +} + +.loading-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 300px; +} + +.error-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 300px; + color: var(--on-surface); + + h2 { + margin-bottom: 8px; + } + + p { + color: var(--outline); + } +} + +.detail-container { + container: bottom-panel / inline-size; + width: 100%; +} + +::ng-deep cr-description-tab { + // Double the ID selector to beat Angular's encapsulated + // #details[_ngcontent-xxx] specificity (0,2,0) with (0,2,1) + #details#details { + height: auto; + overflow-y: visible; + overflow-x: visible; + padding: 0; + background: unset; + } + + #details { + .content { + min-width: 0; + overflow-x: auto; + } + + .details-button { + display: none; + } + + .toc { + height: auto; + max-height: calc(100vh - 100px); + overflow-y: auto; + position: sticky; + top: 80px; + } + } +} diff --git a/projects/website-angular/src/app/content/detail/detail.component.ts b/projects/website-angular/src/app/content/detail/detail.component.ts new file mode 100644 index 0000000..8184079 --- /dev/null +++ b/projects/website-angular/src/app/content/detail/detail.component.ts @@ -0,0 +1,110 @@ +import {Component, inject, OnInit, signal} from '@angular/core'; +import {DatePipe} from '@angular/common'; +import {ActivatedRoute} from '@angular/router'; +import {DomSanitizer} from '@angular/platform-browser'; +import {MatIconRegistry} from '@angular/material/icon'; +import {MatProgressSpinner} from '@angular/material/progress-spinner'; +import {of} from 'rxjs'; + +import {PageLayoutComponent} from '../../page-layout/page-layout.component'; +import { + DescriptionTabComponent +} from '../../../../../pathway-browser/src/app/details/tabs/description-tab/description-tab.component'; +import {SelectableObject} from '../../../../../pathway-browser/src/app/services/event.service'; +import {UrlStateService} from '../../../../../pathway-browser/src/app/services/url-state.service'; +import {DataStateService} from '../../../../../pathway-browser/src/app/services/data-state.service'; +import {EntityService} from '../../../../../pathway-browser/src/app/services/entity.service'; +import {InteractorService} from '../../../../../pathway-browser/src/app/interactors/services/interactor.service'; +import {FigureService} from '../../../../../pathway-browser/src/app/details/tabs/description-tab/figure/figure.service'; +import {SpeciesService} from '../../../../../pathway-browser/src/app/services/species.service'; +import {DiagramService} from '../../../../../pathway-browser/src/app/services/diagram.service'; +import {ParticipantService} from '../../../../../pathway-browser/src/app/services/participant.service'; +import {IconService} from '../../../../../pathway-browser/src/app/services/icon.service'; + +import {DetailDataService} from '../../../services/detail-data.service'; +import {DetailUrlState} from './providers/detail-url-state.provider'; +import {DetailDataState} from './providers/detail-data-state.provider'; +import {DetailEntityService} from './providers/detail-entity.provider'; +import {DetailInteractorService} from './providers/detail-interactor.provider'; +import {DetailFigureService} from './providers/detail-figure.provider'; +import {DetailSpeciesService} from './providers/detail-species.provider'; + +@Component({ + selector: 'app-detail', + standalone: true, + imports: [PageLayoutComponent, DescriptionTabComponent, MatProgressSpinner], + providers: [ + {provide: UrlStateService, useClass: DetailUrlState}, + {provide: DataStateService, useClass: DetailDataState}, + {provide: EntityService, useClass: DetailEntityService}, + {provide: InteractorService, useClass: DetailInteractorService}, + {provide: FigureService, useClass: DetailFigureService}, + {provide: SpeciesService, useClass: DetailSpeciesService}, + DiagramService, + {provide: ParticipantService, useValue: {getReferenceEntities: () => of([])}}, + DatePipe, + ], + templateUrl: './detail.component.html', + styleUrl: './detail.component.scss', +}) +export class DetailComponent implements OnInit { + private route = inject(ActivatedRoute); + private detailDataService = inject(DetailDataService); + private dataState = inject(DataStateService) as unknown as DetailDataState; + private matIconRegistry = inject(MatIconRegistry); + private domSanitizer = inject(DomSanitizer); + private iconService = inject(IconService); + + obj = signal(undefined); + loading = signal(true); + error = signal(false); + + constructor() { + this.registerIcons(); + } + + private registerIcons() { + const speciesIcons = this.iconService.getSpeciesIcons(); + const generalIcons = this.iconService.getGeneralIcons(); + const reactomeSubjectIcons = this.iconService.getReactomeSubjectIcons(); + + this.matIconRegistry.registerFontClassAlias('symbols', 'material-symbols-rounded'); + + speciesIcons.forEach(icon => { + this.matIconRegistry.addSvgIcon(icon.name, this.domSanitizer.bypassSecurityTrustResourceUrl(`assets/icons/species/${icon.route}.svg`)); + }); + + generalIcons.forEach(icon => { + this.matIconRegistry.addSvgIcon(icon.name, this.domSanitizer.bypassSecurityTrustResourceUrl(`assets/icons/general/${icon.route}.svg`)); + }); + + Object.values(reactomeSubjectIcons).forEach((icon) => { + this.matIconRegistry.addSvgIcon(icon.name, this.domSanitizer.bypassSecurityTrustResourceUrl(`assets/icons/reactome-subject/${icon.route}.svg`)); + }); + } + + ngOnInit() { + const id = this.route.snapshot.paramMap.get('id'); + if (!id) { + this.loading.set(false); + this.error.set(true); + return; + } + + this.detailDataService.fetchEnhancedData(id).subscribe({ + next: (data) => { + if (data) { + this.obj.set(data); + this.dataState.selectedElement.set(data); + } else { + this.error.set(true); + } + this.loading.set(false); + }, + error: () => { + this.error.set(true); + this.loading.set(false); + } + }); + } +} diff --git a/projects/website-angular/src/app/content/detail/icon-detail/icon-detail.component.html b/projects/website-angular/src/app/content/detail/icon-detail/icon-detail.component.html new file mode 100644 index 0000000..7f96191 --- /dev/null +++ b/projects/website-angular/src/app/content/detail/icon-detail/icon-detail.component.html @@ -0,0 +1,117 @@ + + @if (loading()) { +
+ +
+ } @else if (error()) { +
+

Icon not found

+

The requested icon could not be found.

+
+ } @else if (icon()) { +
+ +
+
+

{{ icon()!.name }}

+ @if (icon()!.iconCategories?.length) { +
+ @for (cat of icon()!.iconCategories; track cat) { + {{ cat }} + } +
+ } +
+
+ +
+
+
+ + @if (icon()!.summation) { + +

Description

+

+
+ } + + @if (icon()!.iconCuratorName || icon()!.iconDesignerName) { + +

Credits

+
+ @if (icon()!.iconCuratorName) { + + } + @if (icon()!.iconDesignerName) { +
+ Designer: + @if (icon()!.iconDesignerUrl) { + + {{ icon()!.iconDesignerName }} + open_in_new + + } @else { + {{ icon()!.iconDesignerName }} + } +
+ } +
+ + } + + @if (getUniProtReferences().length) { + +

External References

+
+ @for (ref of getUniProtReferences(); track ref) { + + UniProt: {{ ref }} + open_in_new + + } +
+
+ } + + @if (icon()!.iconPhysicalEntities?.length) { + +

Physical Entities ({{ icon()!.iconPhysicalEntities.length }})

+
+ + + + + + + + + + + @for (entity of icon()!.iconPhysicalEntities; track entity.stId) { + + + + + + + } + +
NameIdentifierTypeCompartment
+ {{ entity.displayName || entity.name }} + {{ entity.stId }}{{ entity.type }}{{ entity.compartments }}
+
+
+ } +
+ } + diff --git a/projects/website-angular/src/app/content/detail/icon-detail/icon-detail.component.scss b/projects/website-angular/src/app/content/detail/icon-detail/icon-detail.component.scss new file mode 100644 index 0000000..bad649b --- /dev/null +++ b/projects/website-angular/src/app/content/detail/icon-detail/icon-detail.component.scss @@ -0,0 +1,178 @@ +:host { + display: block; +} + +.loading-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 300px; +} + +.error-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 300px; + color: var(--on-surface); + + h2 { + margin-bottom: 8px; + } + + p { + color: var(--outline); + } +} + +.icon-detail { + display: flex; + flex-direction: column; + gap: 16px; +} + +.icon-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + flex-wrap: wrap; + + .icon-info { + flex: 1; + min-width: 200px; + + h1 { + margin: 0 0 12px; + font-size: 1.5rem; + } + } + + .icon-preview { + flex-shrink: 0; + width: 120px; + height: 120px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--outline, #ddd); + border-radius: 8px; + padding: 12px; + background: white; + + img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } + } +} + +.icon-categories { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.category-tag { + display: inline-block; + padding: 4px 12px; + border-radius: 16px; + background: var(--surface, #f0f0f0); + font-size: 0.85rem; + text-transform: capitalize; +} + +h2 { + margin: 0 0 12px; + font-size: 1.2rem; +} + +.credits { + display: flex; + flex-direction: column; + gap: 8px; +} + +.credit-item { + display: flex; + align-items: center; + gap: 8px; + + .credit-label { + font-weight: 600; + } + + a { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--primary); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + + .material-symbols-rounded { + font-size: 16px; + } + } +} + +.references { + display: flex; + flex-direction: column; + gap: 8px; +} + +.ref-link { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--primary); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + + .material-symbols-rounded { + font-size: 16px; + } +} + +.table-wrapper { + overflow-x: auto; +} + +.entities-table { + width: 100%; + border-collapse: collapse; + + th, td { + padding: 10px 14px; + text-align: left; + border-bottom: 1px solid var(--outline, #ddd); + } + + th { + font-weight: 600; + background: var(--surface, #f5f5f5); + white-space: nowrap; + } + + tbody tr:hover { + background: rgba(0, 0, 0, 0.02); + } + + a { + color: var(--primary); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} diff --git a/projects/website-angular/src/app/content/detail/icon-detail/icon-detail.component.ts b/projects/website-angular/src/app/content/detail/icon-detail/icon-detail.component.ts new file mode 100644 index 0000000..e8cb120 --- /dev/null +++ b/projects/website-angular/src/app/content/detail/icon-detail/icon-detail.component.ts @@ -0,0 +1,52 @@ +import {Component, inject, OnInit, signal} from '@angular/core'; +import {ActivatedRoute, RouterLink} from '@angular/router'; +import {MatProgressSpinner} from '@angular/material/progress-spinner'; +import {PageLayoutComponent} from '../../../page-layout/page-layout.component'; +import {TileComponent} from '../../../reactome-components/tile/tile.component'; +import {IconService, IconEntry} from '../../../../services/icon.service'; + +@Component({ + selector: 'app-icon-detail', + standalone: true, + imports: [PageLayoutComponent, TileComponent, MatProgressSpinner, RouterLink], + templateUrl: './icon-detail.component.html', + styleUrl: './icon-detail.component.scss', +}) +export class IconDetailComponent implements OnInit { + private route = inject(ActivatedRoute); + private iconService = inject(IconService); + + icon = signal(null); + loading = signal(true); + error = signal(false); + + ngOnInit() { + const id = this.route.snapshot.paramMap.get('id'); + if (!id) { + this.loading.set(false); + this.error.set(true); + return; + } + + this.iconService.getIcon(id).subscribe({ + next: (data) => { + this.icon.set(data); + this.loading.set(false); + }, + error: () => { + this.error.set(true); + this.loading.set(false); + }, + }); + } + + getSvgUrl(): string { + const stId = this.icon()?.stId; + return stId ? `https://reactome.org/icon/${stId}.svg` : ''; + } + + getUniProtReferences(): string[] { + const refs = this.icon()?.iconReferences ?? []; + return refs.filter(r => !r.includes(':')); + } +} diff --git a/projects/website-angular/src/app/content/detail/interactor-detail/interactor-detail.component.html b/projects/website-angular/src/app/content/detail/interactor-detail/interactor-detail.component.html new file mode 100644 index 0000000..a1efee2 --- /dev/null +++ b/projects/website-angular/src/app/content/detail/interactor-detail/interactor-detail.component.html @@ -0,0 +1,91 @@ + + @if (loading()) { +
+ +
+ } @else if (error()) { +
+

Interactor not found

+

No interaction data could be found for "{{ acc() }}".

+
+ } @else { +
+ +
+

+ {{ displayName() }} +

+
+
+
Type
+
{{ interactorType() }}
+
+ @if (species()) { +
+
Species
+
{{ species() }}
+
+ } + @if (synonyms().length) { +
+
Synonyms
+
{{ synonyms().join(', ') }}
+
+ } +
+
+
+ + @if (interactions().length) { + +

Interaction Partners ({{ interactions().length }})

+
+ + + + + + + + + + + + @for (interaction of interactions(); track interaction.dbId) { + + + + + + + + } + +
Accession#EntitiesReactome EntityConfidence ScoreEvidence (IntAct)
+ {{ interaction.identifier }} + {{ interaction.entitiesCount ?? '' }} + @if (entityMap()[interaction.identifier]; as entities) { + @for (entity of entities; track entity.stId) { + {{ entity.displayName }} + } + } + {{ interaction.score | number:'1.3-3' }} + @if (interaction.evidenceURL) { + + {{ interaction.evidenceCount }} pieces of evidence + open_in_new + + } @else { + {{ interaction.evidenceCount }} + } +
+
+
+ } @else { + +

No interaction partners found.

+
+ } +
+ } +
diff --git a/projects/website-angular/src/app/content/detail/interactor-detail/interactor-detail.component.scss b/projects/website-angular/src/app/content/detail/interactor-detail/interactor-detail.component.scss new file mode 100644 index 0000000..8442555 --- /dev/null +++ b/projects/website-angular/src/app/content/detail/interactor-detail/interactor-detail.component.scss @@ -0,0 +1,135 @@ +:host { + display: block; +} + +.loading-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 300px; +} + +.error-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 300px; + color: var(--on-surface); + + h2 { + margin-bottom: 8px; + } + + p { + color: var(--outline); + } +} + +.interactor-detail { + display: flex; + flex-direction: column; + gap: 16px; +} + +.interactor-summary { + h1 { + margin: 0 0 16px; + font-size: 1.5rem; + + a { + color: var(--primary); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + + .summary-fields { + margin: 0; + + .summary-row { + display: flex; + gap: 12px; + padding: 6px 0; + border-bottom: 1px solid var(--outline, #eee); + + &:last-child { + border-bottom: none; + } + + dt { + font-weight: 600; + min-width: 100px; + flex-shrink: 0; + } + + dd { + margin: 0; + } + } + } +} + +h2 { + margin: 0 0 16px; + font-size: 1.2rem; +} + +.table-wrapper { + overflow-x: auto; +} + +.interactions-table { + width: 100%; + border-collapse: collapse; + + th, td { + padding: 10px 14px; + text-align: left; + border-bottom: 1px solid var(--outline, #ddd); + } + + th { + font-weight: 600; + background: var(--surface, #f5f5f5); + white-space: nowrap; + } + + tbody tr:hover { + background: rgba(0, 0, 0, 0.02); + } + + a { + color: var(--primary); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + .entities-cell { + .entity-link { + display: block; + padding: 2px 0; + + &:not(:last-child) { + border-bottom: 1px dotted var(--outline, #ddd); + } + } + } + + .evidence-link { + display: inline-flex; + align-items: center; + gap: 4px; + white-space: nowrap; + + .material-symbols-rounded { + font-size: 16px; + } + } +} diff --git a/projects/website-angular/src/app/content/detail/interactor-detail/interactor-detail.component.ts b/projects/website-angular/src/app/content/detail/interactor-detail/interactor-detail.component.ts new file mode 100644 index 0000000..141ce1f --- /dev/null +++ b/projects/website-angular/src/app/content/detail/interactor-detail/interactor-detail.component.ts @@ -0,0 +1,226 @@ +import {Component, inject, OnInit, signal} from '@angular/core'; +import {DecimalPipe} from '@angular/common'; +import {ActivatedRoute, RouterLink} from '@angular/router'; +import {HttpClient} from '@angular/common/http'; +import {MatProgressSpinner} from '@angular/material/progress-spinner'; +import {forkJoin, of} from 'rxjs'; +import {catchError, switchMap} from 'rxjs/operators'; +import {PageLayoutComponent} from '../../../page-layout/page-layout.component'; +import {TileComponent} from '../../../reactome-components/tile/tile.component'; +import {CONTENT_SERVICE} from '../../../../../../pathway-browser/src/environments/environment'; + +export interface CustomInteraction { + dbId: number; + identifier: string; + score: number; + evidenceCount: number; + url: string; + evidenceURL: string; + geneName?: string[]; + databaseName?: string; + entitiesCount?: number; + speciesName?: string; + displayName?: string; +} + +export interface ReactomeEntity { + stId: string; + displayName: string; +} + +interface XrefMapping { + reference: string; + physicalEntities: string[]; +} + +interface SearchResult { + results: { entries: SearchEntry[] }[]; +} + +interface SearchEntry { + name: string; + type: string; + exactType: string; + species: string[]; + databaseName: string; + referenceIdentifier: string; + referenceURL: string; +} + +interface UniProtResponse { + genes?: { geneName?: { value: string }; synonyms?: { value: string }[] }[]; + proteinDescription?: { + recommendedName?: { fullName?: { value: string } }; + alternativeNames?: { fullName?: { value: string } }[]; + }; + organism?: { scientificName?: string }; +} + +@Component({ + selector: 'app-interactor-detail', + standalone: true, + imports: [PageLayoutComponent, TileComponent, MatProgressSpinner, DecimalPipe, RouterLink], + templateUrl: './interactor-detail.component.html', + styleUrl: './interactor-detail.component.scss', +}) +export class InteractorDetailComponent implements OnInit { + private route = inject(ActivatedRoute); + private http = inject(HttpClient); + + acc = signal(''); + interactions = signal([]); + loading = signal(true); + error = signal(false); + + // Summary fields + displayName = signal(''); + interactorType = signal(''); + species = signal(''); + synonyms = signal([]); + referenceURL = signal(''); + + // Reactome entity mapping: interactor identifier -> ReactomeEntity[] + entityMap = signal>({}); + + ngOnInit() { + const acc = this.route.snapshot.paramMap.get('acc'); + if (!acc) { + this.loading.set(false); + this.error.set(true); + return; + } + + this.acc.set(acc); + const baseAcc = acc.split('-')[0]; + + forkJoin({ + interactions: this.http.get( + `${CONTENT_SERVICE}/interactors/static/molecule/enhanced/${encodeURIComponent(acc)}/details` + ).pipe(catchError(() => of(null))), + search: this.http.get( + `${CONTENT_SERVICE}/search/query`, {params: {query: acc, types: 'Interactor', cluster: 'true'}} + ).pipe(catchError(() => of(null))), + uniprot: this.http.get( + `https://rest.uniprot.org/uniprotkb/${encodeURIComponent(baseAcc)}.json` + ).pipe(catchError(() => of(null))), + }).pipe( + switchMap(({interactions, search, uniprot}) => { + if (!interactions) { + return of({interactions: null, search, uniprot, entityMap: {}}); + } + + // Fetch xrefs for each unique interactor identifier to get Reactome entities + const identifiers = interactions.map(i => i.identifier).filter(Boolean); + if (!identifiers.length) { + return of({interactions, search, uniprot, entityMap: {}}); + } + + const xrefRequests: Record> = {}; + for (const id of identifiers) { + xrefRequests[id] = this.http.get( + `${CONTENT_SERVICE}/references/mapping/${encodeURIComponent(id)}/xrefs` + ).pipe(catchError(() => of([]))); + } + + return forkJoin(xrefRequests).pipe( + switchMap((xrefResults: Record) => { + // Collect all physical entity stIds + const allStIds: string[] = []; + const idToStIds: Record = {}; + + for (const [id, xrefs] of Object.entries(xrefResults)) { + const mappings = xrefs as XrefMapping[]; + const stIds: string[] = []; + if (Array.isArray(mappings)) { + for (const m of mappings) { + if (m.physicalEntities) stIds.push(...m.physicalEntities); + } + } + idToStIds[id] = stIds; + allStIds.push(...stIds); + } + + if (!allStIds.length) { + return of({interactions, search, uniprot, entityMap: {}}); + } + + // Batch fetch display names for all physical entities + return this.http.post<{stId: string; displayName: string}[]>( + `${CONTENT_SERVICE}/data/query/ids`, + allStIds.join(','), + {headers: {'Content-Type': 'text/plain'}} + ).pipe( + catchError(() => of([])), + switchMap((entities) => { + const entityLookup: Record = {}; + if (Array.isArray(entities)) { + for (const e of entities) { + if (e?.stId) entityLookup[e.stId] = e.displayName; + } + } + + const entityMap: Record = {}; + for (const [id, stIds] of Object.entries(idToStIds)) { + entityMap[id] = stIds + .filter(stId => entityLookup[stId]) + .map(stId => ({stId, displayName: entityLookup[stId]})); + } + + return of({interactions, search, uniprot, entityMap}); + }) + ); + }) + ); + }) + ).subscribe({ + next: ({interactions, search, uniprot, entityMap}) => { + if (!interactions) { + this.error.set(true); + this.loading.set(false); + return; + } + + this.interactions.set(interactions); + this.entityMap.set(entityMap); + + // Extract summary from search result + const entry = search?.results?.[0]?.entries?.[0]; + if (entry) { + const cleanId = (entry.referenceIdentifier ?? acc).replace(/<[^>]*>/g, ''); + this.displayName.set(`${entry.databaseName}:${cleanId} ${entry.name}`); + this.interactorType.set(`${entry.type}${entry.exactType && entry.exactType !== entry.type ? ' (' + entry.exactType + ')' : ''}`); + this.species.set(entry.species?.[0] ?? ''); + this.referenceURL.set(entry.referenceURL ?? `https://www.uniprot.org/uniprotkb/${acc}/entry`); + } else { + this.displayName.set(`UniProt:${acc}`); + this.interactorType.set('Interactor'); + this.referenceURL.set(`https://www.uniprot.org/uniprotkb/${acc}/entry`); + } + + // Extract synonyms from UniProt + if (uniprot) { + const syns: string[] = []; + for (const gene of uniprot.genes ?? []) { + for (const syn of gene.synonyms ?? []) { + if (syn.value) syns.push(syn.value); + } + } + const protDesc = uniprot.proteinDescription; + for (const alt of protDesc?.alternativeNames ?? []) { + if (alt.fullName?.value) syns.push(alt.fullName.value); + } + if (!this.species()) { + this.species.set(uniprot.organism?.scientificName ?? ''); + } + this.synonyms.set(syns); + } + + this.loading.set(false); + }, + error: () => { + this.error.set(true); + this.loading.set(false); + }, + }); + } +} diff --git a/projects/website-angular/src/app/content/detail/locations-tree/locations-tree.component.html b/projects/website-angular/src/app/content/detail/locations-tree/locations-tree.component.html new file mode 100644 index 0000000..65a1504 --- /dev/null +++ b/projects/website-angular/src/app/content/detail/locations-tree/locations-tree.component.html @@ -0,0 +1,85 @@ +@if (trees().length) { + +
+ @if (availableSpecies().length > 1) { +
+ Species: + +
+ } + +
+ + +
+ @for (root of filteredTrees(); track root.stId) { +
+ @if (hasChildren(root)) { + + + + @if (isExpanded(root.stId)) { +
+
    + @for (child of root.children; track child.url || child.stId; let last = $last) { + + } +
+
+ } + } @else { + +
+ + {{ root.name }} + @if (root.species && availableSpecies().length <= 1) { + ({{ root.species }}) + } +
+ } +
+ } +
+} + + + +
  • +
    + + @if (node.url && !hasChildren(node)) { + {{ node.name }} + } @else { + {{ node.name }} + } +
    + @if (hasChildren(node)) { +
      + @for (child of node.children; track child.url || child.stId; let childLast = $last) { + + } +
    + } +
  • +
    diff --git a/projects/website-angular/src/app/content/detail/locations-tree/locations-tree.component.scss b/projects/website-angular/src/app/content/detail/locations-tree/locations-tree.component.scss new file mode 100644 index 0000000..9d5becf --- /dev/null +++ b/projects/website-angular/src/app/content/detail/locations-tree/locations-tree.component.scss @@ -0,0 +1,214 @@ +:host { + display: block; + width: 100%; + font-family: 'Roboto', sans-serif; +} + +// ── Toolbar (species selector + expand/collapse) ──────── +.toolbar { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.species-filter { + display: flex; + align-items: center; + gap: 6px; +} + +.species-label { + font-size: 14px; + color: var(--on-surface-variant); + white-space: nowrap; +} + +.species-select { + font-family: 'Roboto', sans-serif; + font-size: 14px; + padding: 2px 6px; + border: 1px solid var(--outline-variant); + border-radius: 4px; + background: var(--surface); + color: var(--on-surface); + cursor: pointer; + + &:focus { + outline: 2px solid var(--primary); + outline-offset: -1px; + } +} + +.expand-all-btn { + background: none; + border: none; + cursor: pointer; + font-family: 'Roboto', sans-serif; + font-size: 14px; + font-weight: 500; + color: var(--primary); + white-space: nowrap; + padding: 4px 12px; + border-radius: 50px; + margin-left: auto; + + &:hover { + background: color-mix(in srgb, var(--primary) 8%, transparent); + } +} + +// ── Root entries ──────────────────────────────────────── +.tree-roots { + display: flex; + flex-direction: column; + gap: 2px; +} + +.root-entry { + display: flex; + flex-direction: column; +} + +.root-toggle { + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + background: none; + border: none; + cursor: pointer; + padding: 2px 6px; + border-radius: 50px; + font-family: 'Roboto', sans-serif; + font-size: 14px; + line-height: 24px; + color: var(--on-surface); + text-align: left; + + &:hover { + background: var(--surface2, color-mix(in srgb, var(--on-surface) 8%, transparent)); + } +} + +.root-leaf { + display: flex; + flex-direction: row; + align-items: center; + gap: 4px; + padding: 2px 6px 2px 28px; // align with root-toggle text (arrow width + gap) +} + +.arrow-icon { + width: 20px; + height: 20px; + flex-shrink: 0; +} + +.type-icon { + width: 20px; + height: 20px; + flex-shrink: 0; +} + +.root-name { + overflow: hidden; + text-overflow: ellipsis; +} + +// ── Subtree (children of a root) ──────────────────────── +.subtree-container { + padding-left: 14px; // align with root icon + max-height: 300px; + overflow-y: auto; +} + +// ── Tree list with connector lines ────────────────────── +ul.tree { + list-style: none; + margin: 0; + padding: 0 0 0 10px; + position: relative; + + // Vertical connector line + &::before { + content: ''; + display: block; + width: 0; + position: absolute; + top: 0; + bottom: 0; + left: 0; + border-left: 1px solid var(--outline-variant); + } +} + +ul.tree li { + margin: 0; + padding: 0 0 0 12px; + position: relative; + font-size: 14px; + line-height: 22px; + + // Horizontal connector line + &::before { + content: ''; + display: block; + width: 10px; + height: 0; + border-top: 1px solid var(--outline-variant); + position: absolute; + top: 11px; // half of line-height + left: 0; + } + + // Cut vertical line after last child + &.last::before { + background: var(--surface); + height: auto; + top: 11px; + bottom: 0; + } +} + +.node-row { + display: inline-flex; + flex-direction: row; + align-items: center; + gap: 4px; + padding: 1px 6px 1px 2px; + border-radius: 50px; + min-height: 24px; + + &:hover { + background: var(--surface2, color-mix(in srgb, var(--on-surface) 8%, transparent)); + } +} + +.node-name { + font-size: 14px; + line-height: 20px; + letter-spacing: 0.25px; + color: var(--on-surface); +} + +.leaf-link { + font-size: 14px; + line-height: 20px; + letter-spacing: 0.25px; + color: var(--primary); + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +.node-species { + font-size: 12px; + color: var(--outline); + white-space: nowrap; + margin-left: 2px; +} diff --git a/projects/website-angular/src/app/content/detail/locations-tree/locations-tree.component.ts b/projects/website-angular/src/app/content/detail/locations-tree/locations-tree.component.ts new file mode 100644 index 0000000..f338f3f --- /dev/null +++ b/projects/website-angular/src/app/content/detail/locations-tree/locations-tree.component.ts @@ -0,0 +1,132 @@ +import {Component, computed, effect, inject, input, signal} from '@angular/core'; +import {NgTemplateOutlet} from '@angular/common'; +import {HttpClient} from '@angular/common/http'; +import {MatIcon} from '@angular/material/icon'; +import {CONTENT_SERVICE} from '../../../../../../pathway-browser/src/environments/environment'; +import {IconService} from '../../../../../../pathway-browser/src/app/services/icon.service'; + +export interface PathwayBrowserNode { + stId: string; + name: string; + species: string; + url: string; + type: string; + diagram: boolean; + children?: PathwayBrowserNode[]; +} + +@Component({ + selector: 'app-locations-tree', + standalone: true, + imports: [NgTemplateOutlet, MatIcon], + templateUrl: './locations-tree.component.html', + styleUrl: './locations-tree.component.scss', +}) +export class LocationsTreeComponent { + private http = inject(HttpClient); + private iconService = inject(IconService); + + id = input.required(); + trees = signal([]); + expanded = signal>(new Set()); + allExpanded = signal(false); + loading = signal(false); + selectedSpecies = signal(null); + + availableSpecies = computed(() => { + const species = new Set(); + for (const tree of this.trees()) { + if (tree.species) species.add(tree.species); + } + return [...species].sort(); + }); + + filteredTrees = computed(() => { + const selected = this.selectedSpecies(); + const all = this.trees(); + if (!selected || this.availableSpecies().length <= 1) return all; + return all.filter(t => t.species === selected); + }); + + constructor() { + effect(() => { + const id = this.id(); + if (id) this.fetchLocations(id); + }); + } + + private fetchLocations(id: string) { + this.loading.set(true); + this.expanded.set(new Set()); + this.allExpanded.set(false); + this.selectedSpecies.set(null); + const url = `${CONTENT_SERVICE}/data/detail/${id}/locationsInPWB`; + this.http.get(url).subscribe({ + next: (data) => { + this.trees.set(data); + const species = new Set(); + for (const tree of data) { + if (tree.species) species.add(tree.species); + } + if (species.has('Homo sapiens')) { + this.selectedSpecies.set('Homo sapiens'); + } else if (species.size > 0) { + this.selectedSpecies.set([...species].sort()[0]); + } + this.loading.set(false); + }, + error: () => { + this.trees.set([]); + this.loading.set(false); + }, + }); + } + + toggleRoot(stId: string) { + const current = new Set(this.expanded()); + if (current.has(stId)) { + current.delete(stId); + } else { + current.add(stId); + } + this.expanded.set(current); + } + + toggleAll() { + if (this.allExpanded()) { + this.expanded.set(new Set()); + this.allExpanded.set(false); + } else { + const all = new Set(); + for (const tree of this.filteredTrees()) { + all.add(tree.stId); + } + this.expanded.set(all); + this.allExpanded.set(true); + } + } + + isExpanded(stId: string): boolean { + return this.expanded().has(stId); + } + + hasChildren(node: PathwayBrowserNode): boolean { + return !!node.children?.length; + } + + getIconName(type: string): string { + const icons = this.iconService.getReactomeSubjectIcons(); + return icons[type]?.name ?? 'pathway'; + } + + onSpeciesChange(event: Event) { + const value = (event.target as HTMLSelectElement).value; + this.selectedSpecies.set(value); + this.expanded.set(new Set()); + this.allExpanded.set(false); + } + + isLastChild(parent: PathwayBrowserNode[], node: PathwayBrowserNode): boolean { + return parent[parent.length - 1] === node; + } +} diff --git a/projects/website-angular/src/app/content/detail/providers/detail-data-state.provider.ts b/projects/website-angular/src/app/content/detail/providers/detail-data-state.provider.ts new file mode 100644 index 0000000..63157c2 --- /dev/null +++ b/projects/website-angular/src/app/content/detail/providers/detail-data-state.provider.ts @@ -0,0 +1,57 @@ +import {computed, inject, Injectable, signal} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {DataStateService} from '../../../../../../pathway-browser/src/app/services/data-state.service'; +import {SelectableObject} from '../../../../../../pathway-browser/src/app/services/event.service'; +import {DatabaseObject} from '../../../../../../pathway-browser/src/app/model/graph/database-object.model'; +import {JSOGDeserializer, JSOGObject} from '../../../../../../pathway-browser/src/app/utils/JSOGDeserializer'; +import {CONTENT_SERVICE} from '../../../../../../pathway-browser/src/environments/environment'; +import {rxResource} from '@angular/core/rxjs-interop'; +import {map, Observable, of} from 'rxjs'; + +@Injectable() +export class DetailDataState implements Partial { + private http = inject(HttpClient); + + readonly selectedElement = signal(undefined); + readonly selectedElementLoading = signal(false); + readonly currentPathway = computed(() => undefined); + readonly hasDetail = computed(() => !!this.selectedElement()); + readonly selectIsSummary = computed(() => false); + readonly selectedPathwayStId = signal(undefined); + + readonly flagIdentifiers = computed(() => []); + + flagResource = rxResource({ + request: () => null, + loader: () => of({matches: [], interactsWith: []}) + }); + + fetchEnhancedData(id: string | number | null, params?: Partial<{ + fetchIncomingRelationships: boolean, + summariseReferenceEntity: boolean, + includeDisease: boolean + }>): Observable { + if (id === null) return of(undefined); + const url = `${CONTENT_SERVICE}/data/query/enhanced/v2/${id}`; + return this.http.get(url, { + params: { + fetchIncomingRelationships: true, + summariseReferenceEntity: true, + includeDisease: true, + ...params, + includeRef: true, + view: 'nested-aggregated' + } + }).pipe(map(this.flattenReferences)); + } + + flattenReferences(response: T): T { + const deserializer = new JSOGDeserializer(); + return deserializer.deserialize(response as JSOGObject); + } +} + +export const detailDataStateProvider = { + provide: DataStateService, + useClass: DetailDataState, +}; diff --git a/projects/website-angular/src/app/content/detail/providers/detail-entity.provider.ts b/projects/website-angular/src/app/content/detail/providers/detail-entity.provider.ts new file mode 100644 index 0000000..a40fe13 --- /dev/null +++ b/projects/website-angular/src/app/content/detail/providers/detail-entity.provider.ts @@ -0,0 +1,92 @@ +import {inject, Injectable, signal} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {map, Observable, of} from 'rxjs'; +import {EntityService} from '../../../../../../pathway-browser/src/app/services/entity.service'; +import {CONTENT_SERVICE} from '../../../../../../pathway-browser/src/environments/environment'; +import {PhysicalEntity} from '../../../../../../pathway-browser/src/app/model/graph/physical-entity/physical-entity.model'; +import {DatabaseObject} from '../../../../../../pathway-browser/src/app/model/graph/database-object.model'; +import {ReferenceEntity} from '../../../../../../pathway-browser/src/app/model/graph/reference-entity/reference-entity.model'; +import {DataKeys, Labels} from '../../../../../../pathway-browser/src/app/constants/constants'; +import {JSOGDeserializer, JSOGObject} from '../../../../../../pathway-browser/src/app/utils/JSOGDeserializer'; + +@Injectable() +export class DetailEntityService implements Partial { + private http = inject(HttpClient); + + eventId = signal(undefined); + selectedElement$ = of(undefined); + + _refEntities: any = {value: signal(null)}; + refEntities = signal(null); + + getOtherForms(stId: string): Observable { + const url = `${CONTENT_SERVICE}/data/entity/${stId}/otherForms`; + return this.http.get(url); + } + + getEntityInDepth(id: string | number, depth: number): Observable { + const url = `${CONTENT_SERVICE}/data/entity/${id}/in-depth`; + return this.http.get(url, { + params: { + includeRef: true, + view: 'nested-aggregated', + attributes: 'species,compartment,referenceEntity', + maxDepth: depth, + } + }).pipe(map(this.flattenReferences)); + } + + getEventInDepth(id: string | number, depth: number): Observable { + const url = `${CONTENT_SERVICE}/data/event/${id}/in-depth`; + return this.http.get(url, { + params: { + includeRef: true, + view: 'nested-aggregated', + attributes: 'species,compartment', + maxDepth: depth, + } + }).pipe(map(this.flattenReferences)); + } + + getGroupedData(data: T[], getKey: (item: T) => string): Map { + const grouped = new Map(); + const uniqueKeys = [...new Set(data.map(item => getKey(item)))]; + uniqueKeys.forEach(key => { + grouped.set(key, data.filter(item => getKey(item) === key)); + }); + return grouped; + } + + getTransformedExternalRef(refEntity: ReferenceEntity | undefined) { + if (!refEntity) return []; + const externalRef = {...refEntity}; + const properties = [ + {key: DataKeys.DISPLAY_NAME, label: Labels.EXTERNAL_REFERENCE}, + {key: 'geneName', label: 'Gene Names'}, + {key: 'chain', label: 'Chain'}, + {key: 'referenceGene', label: 'Reference Genes'}, + {key: 'referenceTranscript', label: 'Reference Transcript'} + ]; + const results: { label: string, value: any }[] = []; + for (const property of properties) { + let value = (externalRef as any)[property.key]; + if (!value) continue; + results.push({label: property.label || property.key, value}); + } + return results; + } + + loadRefEntities(id: string) { + this.eventId.set(id); + } + + private flattenReferences(response: T): T { + const deserializer = new JSOGDeserializer(); + return deserializer.deserialize(response as JSOGObject); + } +} + +export const detailEntityProvider = { + provide: EntityService, + useClass: DetailEntityService, +}; diff --git a/projects/website-angular/src/app/content/detail/providers/detail-figure.provider.ts b/projects/website-angular/src/app/content/detail/providers/detail-figure.provider.ts new file mode 100644 index 0000000..f4c8e8b --- /dev/null +++ b/projects/website-angular/src/app/content/detail/providers/detail-figure.provider.ts @@ -0,0 +1,17 @@ +import {Injectable, signal} from '@angular/core'; +import {FigureService} from '../../../../../../pathway-browser/src/app/details/tabs/description-tab/figure/figure.service'; +import {Figure} from '../../../../../../pathway-browser/src/app/model/graph/figure.model'; + +@Injectable() +export class DetailFigureService implements Partial { + readonly expanded = signal
    (undefined); + + toggle(figure: Figure) { + this.expanded.update(prev => prev === figure ? undefined : figure); + } +} + +export const detailFigureProvider = { + provide: FigureService, + useClass: DetailFigureService, +}; diff --git a/projects/website-angular/src/app/content/detail/providers/detail-interactor.provider.ts b/projects/website-angular/src/app/content/detail/providers/detail-interactor.provider.ts new file mode 100644 index 0000000..21d6cb0 --- /dev/null +++ b/projects/website-angular/src/app/content/detail/providers/detail-interactor.provider.ts @@ -0,0 +1,24 @@ +import {inject, Injectable, signal} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {Observable, of} from 'rxjs'; +import {InteractorService} from '../../../../../../pathway-browser/src/app/interactors/services/interactor.service'; +import {CONTENT_SERVICE} from '../../../../../../pathway-browser/src/environments/environment'; +import {CustomInteraction} from '../../../../../../pathway-browser/src/app/interactors/model/interactor.model'; + +@Injectable() +export class DetailInteractorService implements Partial { + private http = inject(HttpClient); + + currentResource = signal({type: null, name: null}); + identifiers = ''; + + getCustomInteractorsByAcc(acc: string): Observable { + const url = `${CONTENT_SERVICE}/interactors/static/molecule/enhanced/${acc}/details`; + return this.http.get(url); + } +} + +export const detailInteractorProvider = { + provide: InteractorService, + useClass: DetailInteractorService, +}; diff --git a/projects/website-angular/src/app/content/detail/providers/detail-species.provider.ts b/projects/website-angular/src/app/content/detail/providers/detail-species.provider.ts new file mode 100644 index 0000000..3d92a84 --- /dev/null +++ b/projects/website-angular/src/app/content/detail/providers/detail-species.provider.ts @@ -0,0 +1,23 @@ +import {computed, Injectable, signal} from '@angular/core'; +import {SpeciesService} from '../../../../../../pathway-browser/src/app/services/species.service'; +import {Species} from '../../../../../../pathway-browser/src/app/model/graph/species.model'; + +@Injectable() +export class DetailSpeciesService implements Partial { + readonly defaultSpecies: Species = { + displayName: 'Homo sapiens', + taxId: '9606', + dbId: 48887, + shortName: 'H. sapiens', + abbreviation: 'HSA', + schemaClass: 'Species' + }; + + readonly currentSpecies = signal(this.defaultSpecies); + readonly allShortenSpecies = computed(() => undefined); +} + +export const detailSpeciesProvider = { + provide: SpeciesService, + useClass: DetailSpeciesService, +}; diff --git a/projects/website-angular/src/app/content/detail/providers/detail-url-state.provider.ts b/projects/website-angular/src/app/content/detail/providers/detail-url-state.provider.ts new file mode 100644 index 0000000..2d0e48b --- /dev/null +++ b/projects/website-angular/src/app/content/detail/providers/detail-url-state.provider.ts @@ -0,0 +1,67 @@ +import {inject, Injectable, signal} from '@angular/core'; +import {ActivatedRoute} from '@angular/router'; +import {toSignal} from '@angular/core/rxjs-interop'; +import {UrlStateService, urlParam} from '../../../../../../pathway-browser/src/app/services/url-state.service'; + +@Injectable() +export class DetailUrlState implements Partial { + private route = inject(ActivatedRoute); + + section = toSignal(this.route.fragment); + summariseDisease = urlParam(undefined, 'boolean'); + + select = urlParam(null, 'id'); + flag = urlParam([], 'id'); + path = urlParam([], 'id'); + pathwayId = signal(undefined); + flagInteractors = urlParam(false, 'boolean'); + tab = urlParam(null, 'string'); + overlay = urlParam(null, 'string'); + analysis = urlParam(null, 'string'); + significance = urlParam(0.05, 'number'); + sample = urlParam(null, 'string'); + palette = urlParam(null, 'string'); + filterViewMode = urlParam(undefined, 'string'); + speciesFilter = urlParam([], 'string'); + resourceFilter = urlParam(null, 'string'); + includeDisease = urlParam(undefined, 'boolean'); + includeGrouping = urlParam(undefined, 'boolean'); + pathwayMinSizeFilter = urlParam(undefined, 'number'); + pathwayMaxSizeFilter = urlParam(undefined, 'number'); + minExpressionFilter = urlParam(undefined, 'number'); + maxExpressionFilter = urlParam(undefined, 'number'); + fdrFilter = urlParam(undefined, 'number'); + gsaFilter = urlParam([], 'number'); + example = urlParam(null, 'string'); + + readonly values = { + select: this.select, + flag: this.flag, + path: this.path, + flagInteractors: this.flagInteractors, + overlay: this.overlay, + analysis: this.analysis, + tab: this.tab, + significance: this.significance, + sample: this.sample, + palette: this.palette, + filterViewMode: this.filterViewMode, + speciesFilter: this.speciesFilter, + resourceFilter: this.resourceFilter, + includeDisease: this.includeDisease, + includeGrouping: this.includeGrouping, + pathwayMinSizeFilter: this.pathwayMinSizeFilter, + pathwayMaxSizeFilter: this.pathwayMaxSizeFilter, + minExpressionFilter: this.minExpressionFilter, + maxExpressionFilter: this.maxExpressionFilter, + fdrFilter: this.fdrFilter, + gsaFilter: this.gsaFilter, + summariseDisease: this.summariseDisease, + example: this.example, + }; +} + +export const detailUrlStateProvider = { + provide: UrlStateService, + useClass: DetailUrlState, +}; diff --git a/projects/website-angular/src/app/search/search.component.html b/projects/website-angular/src/app/search/search.component.html index fef3d95..b77b750 100644 --- a/projects/website-angular/src/app/search/search.component.html +++ b/projects/website-angular/src/app/search/search.component.html @@ -281,7 +281,7 @@

    {{ group.typeName }} ({{ group.entriesCount }})

    @for (entry of group.entries; track entry.dbId) {
    - + {{ entry.stId }}
    @if (entry.species?.length) { @@ -318,7 +318,7 @@

    {{ group.typeName }} ({{ group.entriesCount }})

    @for (entry of allEntries; track entry.dbId) {
    - + {{ entry.stId }}
    @if (entry.species?.length) { diff --git a/projects/website-angular/src/app/search/search.component.ts b/projects/website-angular/src/app/search/search.component.ts index c0be74e..8f8abb4 100644 --- a/projects/website-angular/src/app/search/search.component.ts +++ b/projects/website-angular/src/app/search/search.component.ts @@ -303,6 +303,12 @@ export class SearchComponent implements OnInit, OnDestroy, AfterViewInit { return active; } + getDetailLink(entry: SearchEntry): string { + if (entry.exactType === 'Interactor') return '/content/detail/interactor/' + entry.stId; + if (entry.exactType === 'Icon') return '/content/detail/icon/' + entry.stId; + return '/content/detail/' + entry.stId; + } + getPageNumbers(): number[] { const pages: number[] = []; const maxVisible = 5; diff --git a/projects/website-angular/src/services/detail-data.service.ts b/projects/website-angular/src/services/detail-data.service.ts new file mode 100644 index 0000000..b16d043 --- /dev/null +++ b/projects/website-angular/src/services/detail-data.service.ts @@ -0,0 +1,32 @@ +import {Injectable} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {map, Observable, of} from 'rxjs'; +import {CONTENT_SERVICE} from '../../../../projects/pathway-browser/src/environments/environment'; +import {JSOGDeserializer, JSOGObject} from '../../../../projects/pathway-browser/src/app/utils/JSOGDeserializer'; +import {DatabaseObject} from '../../../../projects/pathway-browser/src/app/model/graph/database-object.model'; + +@Injectable({ + providedIn: 'root' +}) +export class DetailDataService { + + constructor(private http: HttpClient) { + } + + fetchEnhancedData(id: string | number | null): Observable { + if (id === null) return of(undefined); + const url = `${CONTENT_SERVICE}/data/query/enhanced/v2/${id}`; + return this.http.get(url, { + params: { + fetchIncomingRelationships: true, + summariseReferenceEntity: true, + includeDisease: true, + includeRef: true, + view: 'nested-aggregated' + } + }).pipe(map(response => { + const deserializer = new JSOGDeserializer(); + return deserializer.deserialize(response as JSOGObject); + })); + } +} diff --git a/projects/website-angular/src/services/icon.service.ts b/projects/website-angular/src/services/icon.service.ts index fbe001c..f88908f 100644 --- a/projects/website-angular/src/services/icon.service.ts +++ b/projects/website-angular/src/services/icon.service.ts @@ -17,6 +17,14 @@ export interface IconFacetResponse { iconCategoriesFacet: IconFacet; } +export interface IconPhysicalEntity { + stId: string; + type: string; + name: string; + compartments: string; + displayName: string; +} + export interface IconEntry { stId: string; name: string; @@ -24,9 +32,13 @@ export interface IconEntry { iconCategories: string[]; iconReferences: string[]; iconEhlds: string[]; - iconPhysicalEntities: string[]; + iconPhysicalEntities: IconPhysicalEntity[]; summation: string; exactType: string; + iconCuratorName?: string; + iconCuratorOrcidId?: string; + iconDesignerName?: string; + iconDesignerUrl?: string; } export interface IconResult { diff --git a/src/app/app.config.ts b/src/app/app.config.ts index a11e1bc..1c321d2 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,6 +1,7 @@ import { ApplicationConfig, provideZoneChangeDetection, importProvidersFrom, ENVIRONMENT_INITIALIZER, inject } from '@angular/core'; import { provideHttpClient, withFetch } from '@angular/common/http'; import { provideRouter, Router, Event, NavigationStart, NavigationEnd } from '@angular/router'; +import { provideAnimations } from '@angular/platform-browser/animations'; import { StoreModule } from '@ngrx/store'; import { StoreRouterConnectingModule, routerReducer } from '@ngrx/router-store'; import { EffectsModule } from '@ngrx/effects'; @@ -12,6 +13,7 @@ export const appConfig: ApplicationConfig = { provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideHttpClient(withFetch()), + provideAnimations(), importProvidersFrom( StoreModule.forRoot({ router: routerReducer }), StoreRouterConnectingModule.forRoot(), diff --git a/src/index.html b/src/index.html index 4860c52..32cea79 100644 --- a/src/index.html +++ b/src/index.html @@ -11,6 +11,8 @@ + +