From 7d061ff84f33a93d3f2201dcd72800703872f775 Mon Sep 17 00:00:00 2001 From: Chryseis Liu <130321313+Chryseisliu@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:04:26 -0700 Subject: [PATCH 1/2] Add DeltaSignal pathway perturbation UI --- documentation/PathwayBrowser/deltasignal.md | 59 +++ .../PathwayBrowser/pathway-browser.md | 3 + .../deltasignal-panel.component.html | 211 +++++++++ .../deltasignal-panel.component.scss | 404 ++++++++++++++++++ .../deltasignal-panel.component.ts | 60 +++ .../src/app/deltasignal/deltasignal.model.ts | 62 +++ .../app/deltasignal/deltasignal.service.ts | 193 +++++++++ .../app/deltasignal/deltasignal.utils.spec.ts | 91 ++++ .../src/app/deltasignal/deltasignal.utils.ts | 84 ++++ .../src/app/diagram/diagram.component.ts | 46 +- .../src/app/viewport/viewport.component.html | 23 + .../src/app/viewport/viewport.component.ts | 8 +- proxy.conf.js | 6 + 13 files changed, 1248 insertions(+), 2 deletions(-) create mode 100644 documentation/PathwayBrowser/deltasignal.md create mode 100644 projects/pathway-browser/src/app/deltasignal/deltasignal-panel.component.html create mode 100644 projects/pathway-browser/src/app/deltasignal/deltasignal-panel.component.scss create mode 100644 projects/pathway-browser/src/app/deltasignal/deltasignal-panel.component.ts create mode 100644 projects/pathway-browser/src/app/deltasignal/deltasignal.model.ts create mode 100644 projects/pathway-browser/src/app/deltasignal/deltasignal.service.ts create mode 100644 projects/pathway-browser/src/app/deltasignal/deltasignal.utils.spec.ts create mode 100644 projects/pathway-browser/src/app/deltasignal/deltasignal.utils.ts diff --git a/documentation/PathwayBrowser/deltasignal.md b/documentation/PathwayBrowser/deltasignal.md new file mode 100644 index 00000000..e76b3987 --- /dev/null +++ b/documentation/PathwayBrowser/deltasignal.md @@ -0,0 +1,59 @@ +# DeltaSignal pathway perturbation prototype + +The Pathway Browser's **Perturb** action is a focused end-to-end integration +with the DeltaSignal steady-state solver. It lets a user select an entity on a +Reactome pathway, define one or more perturbations, run the solver, and view the +predicted response on the existing Reactome diagram. + +## User flow + +1. Open a pathway for which DeltaSignal has a generated network. +2. Select a physical entity on the diagram. +3. Choose **Perturb**. +4. Set activity on the 0–100 DeltaSignal input scale and add the perturbation. +5. Repeat for any additional inputs, then choose **Run DeltaSignal**. +6. Inspect convergence, predicted activities, changes from baseline, influence + scores, and the diverging diagram overlay. + +One Reactome entity can map to several logic-network UUIDs. The same input is +applied to all matching UUIDs. Result rows are aggregated to one row per +Reactome stable identifier for readability, while the diagram preserves all +member values and renders them as a gradient. + +## API and scale + +The client uses the DeltaSignal API without sharing implementation code: + +- `GET /api/pathways` +- `POST /api/parse { pathway_id }` +- `POST /api/solve { network_id, observations }` + +Input activity uses the solver's 0–100 UI scale, where `0` is a knockout and +`1` is normal baseline activity. Solver output is returned on a 0–1 internal +scale and is multiplied by 100 before display. Diagram colour represents the +change from each node's own baseline, not its absolute activity. + +## Local development + +Run the DeltaSignal server on port 8080 with a generated pathway catalog, then +start this workspace with separate Reactome and DeltaSignal backends: + +```sh +DS_PATHWAY_CATALOG=/path/to/logic-network-generator/output \ + julia --project=/path/to/deltasignal /path/to/deltasignal/src/api/server.jl \ + --host=127.0.0.1 --port=8080 + +REACTOME_BACKEND=https://reactome.org \ +DELTASIGNAL_BACKEND=http://127.0.0.1:8080 \ +npm run start:simple -- --host 127.0.0.1 +``` + +The Angular development proxy sends `/api` to `DELTASIGNAL_BACKEND`. If the +variable is omitted, it defaults to `http://localhost:8080`. + +## Deliberate scope + +This prototype covers one pathway and steady-state prediction. It does not yet +join several pathways, overlay results on ReacFoam, represent temporal order, +or expose model benchmarking. Those are follow-on features rather than hidden +behaviour in this UI. diff --git a/documentation/PathwayBrowser/pathway-browser.md b/documentation/PathwayBrowser/pathway-browser.md index dc0c37a2..163f3b70 100644 --- a/documentation/PathwayBrowser/pathway-browser.md +++ b/documentation/PathwayBrowser/pathway-browser.md @@ -1,4 +1,7 @@ # PathwayBrowser [Userguide](http://localhost:4200/documentation/userguide/pathway-browser) + +[DeltaSignal pathway perturbation prototype](./deltasignal.md) + Dev docs loading... diff --git a/projects/pathway-browser/src/app/deltasignal/deltasignal-panel.component.html b/projects/pathway-browser/src/app/deltasignal/deltasignal-panel.component.html new file mode 100644 index 00000000..fa35fc67 --- /dev/null +++ b/projects/pathway-browser/src/app/deltasignal/deltasignal-panel.component.html @@ -0,0 +1,211 @@ +
+
+
Pathway perturbation prototype
+

DeltaSignal

+

Change one or more entities, solve the steady state, and overlay the predicted response.

+
+ +
+ +@if (service.status() === 'loading') { +
+ + Preparing the pathway network… +
+} @else if (service.error() && !service.network()) { + +} @else if (service.network(); as network) { +
+
+
+
+ 1 +

Choose an input

+
+ {{ network.nodes.length }} logic nodes +
+ + @if (selectedNodes().length) { +
+
+ Selected in diagram + {{ selectedName() || selectedStId() }} + + {{ selectedStId() }} · {{ selectedNodes().length }} matching logic + {{ selectedNodes().length === 1 ? 'node' : 'nodes' }} + +
+ check_circle +
+ } @else { +
+ touch_app +
+ Select a molecule on the pathway diagram + + @if (selectedStId()) { + {{ selectedStId() }} is not represented in this DeltaSignal network. Choose another entity. + } @else { + The selected Reactome entity will be matched to its logic-network node or nodes. + } + +
+
+ } + +
+
+ +
+ + × baseline +
+
+ +
+ + + +
+ +
+
+ +
+
+
+ 2 +

Run prediction

+
+ @if (service.perturbations().length) { + + } +
+ + @if (service.perturbations().length) { +
+ @for (item of service.perturbations(); track item.reactomeId) { +
+
+ {{ item.name }} + {{ item.reactomeId }} · {{ item.activity }}× · {{ item.nodeUuids.length }} logic + {{ item.nodeUuids.length === 1 ? 'node' : 'nodes' }} +
+ +
+ } +
+ + } @else { +

Add at least one perturbation to run a prediction.

+ } + + @if (service.error() && service.network()) { + + } +
+ + @if (service.result(); as result) { +
+
+
+ 3 +

Predicted response

+
+ +
+ +
+
+ {{ result.converged ? 'check_circle' : 'warning' }} + {{ result.converged ? 'Converged' : 'Did not converge' }} +
+ {{ result.iterations }} iterations · {{ result.solve_time | number: '1.3-3' }} s +
+ +
+ Lower than baseline +
+ Higher than baseline +
+

+ Diagram colours show change from each node's own baseline. When several logic nodes map to one Reactome + entity, all values appear as a gradient. +

+ +
+
+ EntityActivityChangeInfluence +
+ @for (row of visibleRows(); track row.uuid) { +
+ + {{ row.name }} + {{ row.reactomeId }} + + {{ row.activity | number: '1.1-1' }}× + + {{ row.change > 0 ? '+' : '' }}{{ row.change | number: '1.1-1' }} + + {{ row.influence | number: '1.2-2' }} +
+ } +
+ @if (service.entityRows().length > visibleRows().length) { +

+ Showing the 40 largest predicted changes across {{ service.entityRows().length }} Reactome entities. +

+ } +
+ } +
+} diff --git a/projects/pathway-browser/src/app/deltasignal/deltasignal-panel.component.scss b/projects/pathway-browser/src/app/deltasignal/deltasignal-panel.component.scss new file mode 100644 index 00000000..ee05d750 --- /dev/null +++ b/projects/pathway-browser/src/app/deltasignal/deltasignal-panel.component.scss @@ -0,0 +1,404 @@ +:host { + display: block; + height: 100%; + overflow: auto; + color: var(--on-surface); + background: + radial-gradient( + circle at 80% 0%, + color-mix(in srgb, var(--primary) 10%, transparent), + transparent 32rem + ), + var(--surface); +} + +header { + position: sticky; + top: 0; + z-index: 2; + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 24px; + padding: 24px 32px 18px; + border-bottom: 1px solid var(--outline-variant); + background: color-mix(in srgb, var(--surface) 94%, transparent); + backdrop-filter: blur(12px); + + h2 { + margin: 2px 0 4px; + color: var(--primary); + font: var(--mat-sys-headline-medium); + } + + p { + margin: 0; + color: var(--on-surface-variant); + } +} + +.eyebrow { + color: var(--on-surface-variant); + font: var(--mat-sys-label-small); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +main { + display: grid; + grid-template-columns: minmax(280px, 0.9fr) minmax(300px, 1.1fr); + gap: 20px; + max-width: 1240px; + margin: 0 auto; + padding: 24px 32px 40px; + box-sizing: border-box; +} + +.card { + padding: 22px; + border: 1px solid var(--outline-variant); + border-radius: 18px; + background: color-mix(in srgb, var(--surface-container) 72%, var(--surface)); + box-shadow: 0 10px 28px color-mix(in srgb, var(--shadow) 8%, transparent); +} + +.results { + grid-column: 1 / -1; +} + +.section-heading, +.section-heading > div, +.summary, +.summary > div { + display: flex; + align-items: center; +} + +.section-heading { + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; + + > div { + gap: 10px; + } + + h3 { + margin: 0; + font: var(--mat-sys-title-large); + } +} + +.step { + display: grid; + width: 28px; + height: 28px; + place-items: center; + border-radius: 50%; + color: var(--on-primary); + background: var(--primary); + font: var(--mat-sys-label-large); +} + +.network-count { + color: var(--on-surface-variant); + font: var(--mat-sys-label-medium); +} + +.selected-entity, +.selection-help, +.perturbation { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border-radius: 12px; +} + +.selected-entity { + padding: 14px 16px; + color: var(--on-primary-container); + background: var(--primary-container); + + > div, + .stable-id { + display: flex; + flex-direction: column; + } + + .label, + .stable-id { + font: var(--mat-sys-label-small); + } + + mat-icon { + color: var(--primary); + } +} + +.selection-help { + justify-content: flex-start; + padding: 16px; + border: 1px dashed var(--outline); + color: var(--on-surface-variant); + + > div { + display: flex; + flex-direction: column; + gap: 3px; + } +} + +.activity-control { + margin-top: 22px; + + &.disabled { + opacity: 0.55; + } +} + +.activity-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + + label { + font: var(--mat-sys-title-medium); + } +} + +.activity-value { + display: flex; + align-items: baseline; + gap: 5px; + color: var(--on-surface-variant); + font: var(--mat-sys-label-medium); + + input { + width: 64px; + padding: 6px 8px; + border: 1px solid var(--outline); + border-radius: 8px; + color: var(--on-surface); + background: var(--surface); + font: var(--mat-sys-title-medium); + } +} + +.range { + width: 100%; + margin: 18px 0 10px; + accent-color: var(--primary); +} + +.presets { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; + + button { + padding-inline: 5px; + font-size: 0.75rem; + } +} + +.add-button, +.run-button { + width: 100%; + margin-top: 14px; +} + +.perturbations { + display: flex; + flex-direction: column; + gap: 8px; +} + +.perturbation { + padding: 10px 8px 10px 14px; + border: 1px solid var(--outline-variant); + background: var(--surface); + + > div { + display: flex; + min-width: 0; + flex-direction: column; + } + + strong, + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + span { + color: var(--on-surface-variant); + font: var(--mat-sys-label-small); + } +} + +.run-button mat-spinner { + display: inline-block; + margin-right: 8px; +} + +.empty, +.method-note, +.truncated { + color: var(--on-surface-variant); +} + +.inline-error { + display: flex; + align-items: center; + gap: 8px; + margin-top: 14px; + color: var(--error); +} + +.summary { + justify-content: space-between; + gap: 16px; + padding: 12px 14px; + border-radius: 10px; + color: var(--on-primary-container); + background: var(--primary-container); + + > div { + gap: 8px; + } + + &.warning { + color: var(--on-error-container); + background: var(--error-container); + } +} + +.legend { + display: grid; + grid-template-columns: auto minmax(120px, 1fr) auto; + align-items: center; + gap: 12px; + margin-top: 20px; + color: var(--on-surface-variant); + font: var(--mat-sys-label-small); +} + +.gradient { + height: 10px; + border-radius: 50px; + background: linear-gradient(to right in oklab, #a23b72, #f4f1ed, #1b7f8c); +} + +.method-note { + margin: 8px 0 18px; + font: var(--mat-sys-body-small); +} + +.result-table { + max-height: 360px; + overflow: auto; + border: 1px solid var(--outline-variant); + border-radius: 12px; +} + +.result-row { + display: grid; + grid-template-columns: minmax(180px, 1fr) 100px 100px 100px; + gap: 12px; + align-items: center; + min-height: 48px; + padding: 8px 14px; + border-bottom: 1px solid var(--outline-variant); + box-sizing: border-box; + + &:last-child { + border-bottom: 0; + } + + &.header { + position: sticky; + top: 0; + z-index: 1; + min-height: 38px; + color: var(--on-surface-variant); + background: var(--surface-container-high); + font: var(--mat-sys-label-medium); + } + + &.perturbed { + background: color-mix(in srgb, var(--primary-container) 45%, transparent); + } + + .entity { + display: flex; + min-width: 0; + flex-direction: column; + + strong, + small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + small { + color: var(--on-surface-variant); + } + } +} + +.positive { + color: #087b83; +} + +.negative { + color: #a23b72; +} + +.state-message { + display: flex; + min-height: 300px; + align-items: center; + justify-content: center; + gap: 14px; + padding: 40px; + color: var(--on-surface-variant); + + &.error > div { + display: flex; + max-width: 520px; + flex-direction: column; + } +} + +@media (max-width: 900px) { + main { + grid-template-columns: 1fr; + padding: 18px; + } + + .results { + grid-column: auto; + } + + header { + padding-inline: 20px; + } +} + +@media (max-width: 620px) { + .result-row { + grid-template-columns: minmax(140px, 1fr) 72px 72px; + + > :last-child { + display: none; + } + } + + .legend { + grid-template-columns: 1fr; + } +} diff --git a/projects/pathway-browser/src/app/deltasignal/deltasignal-panel.component.ts b/projects/pathway-browser/src/app/deltasignal/deltasignal-panel.component.ts new file mode 100644 index 00000000..9e62ba59 --- /dev/null +++ b/projects/pathway-browser/src/app/deltasignal/deltasignal-panel.component.ts @@ -0,0 +1,60 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + input, + output, + signal, +} from '@angular/core'; +import { DecimalPipe } from '@angular/common'; +import { MatButton, MatIconButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { MatTooltip } from '@angular/material/tooltip'; +import { DeltaSignalService } from './deltasignal.service'; + +@Component({ + selector: 'cr-deltasignal-panel', + templateUrl: './deltasignal-panel.component.html', + styleUrl: './deltasignal-panel.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [DecimalPipe, MatButton, MatIconButton, MatIcon, MatProgressSpinner, MatTooltip], +}) +export class DeltaSignalPanelComponent { + readonly service = inject(DeltaSignalService); + + readonly pathwayId = input.required(); + readonly selectedStId = input(null); + readonly status = input.required<'open' | 'closed'>(); + readonly dismissed = output(); + + readonly activity = signal(0); + readonly selectedNodes = computed(() => this.service.matchingNodes(this.selectedStId())); + readonly selectedName = computed(() => + [...new Set(this.selectedNodes().map((node) => node.name))].join(' / ') + ); + readonly visibleRows = computed(() => this.service.entityRows().slice(0, 40)); + + constructor() { + effect(() => { + const pathwayId = this.pathwayId(); + const loadedPathway = this.service.pathway(); + if (loadedPathway && loadedPathway.stable_id !== pathwayId) this.service.reset(); + }); + effect(() => { + if (this.status() === 'open') void this.service.loadPathway(this.pathwayId()); + }); + } + + setActivity(value: string | number) { + const parsed = typeof value === 'number' ? value : Number(value); + if (Number.isFinite(parsed)) this.activity.set(Math.min(100, Math.max(0, parsed))); + } + + addSelected() { + const stableId = this.selectedStId(); + if (stableId) this.service.addPerturbation(stableId, this.activity()); + } +} diff --git a/projects/pathway-browser/src/app/deltasignal/deltasignal.model.ts b/projects/pathway-browser/src/app/deltasignal/deltasignal.model.ts new file mode 100644 index 00000000..14f07903 --- /dev/null +++ b/projects/pathway-browser/src/app/deltasignal/deltasignal.model.ts @@ -0,0 +1,62 @@ +export interface DeltaSignalPathway { + id: string; + stable_id: string; + name: string; +} + +export interface DeltaSignalNode { + uuid: string; + name: string; + reactome_id: string; + entity_type: string; + baseline: number; + set_id: string | null; +} + +export interface DeltaSignalEdge { + parent_uuid: string; + child_uuid: string; + is_and: boolean; + is_positive: boolean; + stoichiometry: number; + edge_type: string; +} + +export interface DeltaSignalNetwork { + status: 'success'; + message: string; + network_id: string; + nodes: DeltaSignalNode[]; + edges: DeltaSignalEdge[]; + pathways: { id: string; name: string; members: string[] }[]; +} + +export interface DeltaSignalSolveResult { + status: 'success'; + message: string; + node_activities: Record; + influence_scores: Record; + converged: boolean; + iterations: number; + solve_time: number; +} + +export interface DeltaSignalPerturbation { + reactomeId: string; + name: string; + activity: number; + nodeUuids: string[]; +} + +export interface DeltaSignalResultRow { + uuid: string; + reactomeId: string; + name: string; + baseline: number; + activity: number; + change: number; + influence: number; + perturbed: boolean; +} + +export type DeltaSignalStatus = 'idle' | 'loading' | 'ready' | 'solving' | 'error'; diff --git a/projects/pathway-browser/src/app/deltasignal/deltasignal.service.ts b/projects/pathway-browser/src/app/deltasignal/deltasignal.service.ts new file mode 100644 index 00000000..6093c84c --- /dev/null +++ b/projects/pathway-browser/src/app/deltasignal/deltasignal.service.ts @@ -0,0 +1,193 @@ +import { computed, inject, Injectable, signal } from '@angular/core'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; +import chroma from 'chroma-js'; +import { + DeltaSignalNetwork, + DeltaSignalPathway, + DeltaSignalPerturbation, + DeltaSignalSolveResult, + DeltaSignalStatus, +} from './deltasignal.model'; +import { + aggregateRowsByReactomeId, + buildObservations, + groupChangesByReactomeId, + resultRows, +} from './deltasignal.utils'; + +@Injectable({ providedIn: 'root' }) +export class DeltaSignalService { + private readonly http = inject(HttpClient); + private catalogRequest?: Promise; + private loadGeneration = 0; + + readonly status = signal('idle'); + readonly error = signal(null); + readonly pathway = signal(null); + readonly network = signal(null); + readonly perturbations = signal([]); + readonly result = signal(null); + + readonly rows = computed(() => resultRows(this.network(), this.result(), this.perturbations())); + readonly entityRows = computed(() => aggregateRowsByReactomeId(this.rows())); + readonly overlay = computed(() => groupChangesByReactomeId(this.rows())); + readonly hasOverlay = computed(() => this.overlay().size > 0); + readonly maxAbsoluteChange = computed(() => + Math.max(1, ...this.rows().map((row) => Math.abs(row.change))) + ); + readonly palette = computed(() => { + const extent = this.maxAbsoluteChange(); + return chroma + .scale(['#a23b72', '#f4f1ed', '#1b7f8c']) + .mode('oklab') + .domain([-extent, 0, extent]); + }); + + matchingNodes(reactomeId: string | null | undefined) { + if (!reactomeId) return []; + return this.network()?.nodes.filter((node) => node.reactome_id === reactomeId) ?? []; + } + + async loadPathway(stableId: string): Promise { + if (this.pathway()?.stable_id === stableId && this.network()) return; + + const generation = ++this.loadGeneration; + this.status.set('loading'); + this.error.set(null); + this.pathway.set(null); + this.network.set(null); + this.perturbations.set([]); + this.result.set(null); + + try { + const catalog = await this.getCatalog(); + if (generation !== this.loadGeneration) return; + + const pathway = catalog.find((candidate) => candidate.stable_id === stableId); + if (!pathway) { + this.status.set('error'); + this.error.set(`DeltaSignal does not yet have a generated network for ${stableId}.`); + return; + } + + const network = await this.parse(pathway); + if (generation !== this.loadGeneration) return; + this.pathway.set(pathway); + this.network.set(network); + this.status.set('ready'); + } catch (error) { + if (generation !== this.loadGeneration) return; + this.status.set('error'); + this.error.set(this.errorMessage(error, 'Could not load the DeltaSignal network.')); + } + } + + addPerturbation(reactomeId: string, activity: number): boolean { + const nodes = this.matchingNodes(reactomeId); + if (!nodes.length) return false; + + const boundedActivity = Math.min(100, Math.max(0, activity)); + const perturbation: DeltaSignalPerturbation = { + reactomeId, + name: [...new Set(nodes.map((node) => node.name))].join(' / '), + activity: boundedActivity, + nodeUuids: nodes.map((node) => node.uuid), + }; + this.perturbations.update((items) => [ + ...items.filter((item) => item.reactomeId !== reactomeId), + perturbation, + ]); + this.result.set(null); + return true; + } + + removePerturbation(reactomeId: string) { + this.perturbations.update((items) => items.filter((item) => item.reactomeId !== reactomeId)); + this.result.set(null); + } + + clear() { + this.perturbations.set([]); + this.result.set(null); + this.error.set(null); + } + + clearResult() { + this.result.set(null); + } + + reset() { + this.loadGeneration += 1; + this.status.set('idle'); + this.error.set(null); + this.pathway.set(null); + this.network.set(null); + this.perturbations.set([]); + this.result.set(null); + } + + async solve(): Promise { + const network = this.network(); + const pathway = this.pathway(); + if (!network || !pathway || !this.perturbations().length) return; + + this.status.set('solving'); + this.error.set(null); + const observations = buildObservations(this.perturbations()); + + try { + this.result.set(await this.requestSolve(network.network_id, observations)); + this.status.set('ready'); + } catch (error) { + let solveError = error; + if (error instanceof HttpErrorResponse && error.status === 404) { + try { + const reparsed = await this.parse(pathway); + this.network.set(reparsed); + this.result.set(await this.requestSolve(reparsed.network_id, observations)); + this.status.set('ready'); + return; + } catch (retryError) { + solveError = retryError; + } + } + this.status.set('error'); + this.error.set( + this.errorMessage(solveError, 'DeltaSignal could not solve this perturbation.') + ); + } + } + + private getCatalog() { + this.catalogRequest ??= firstValueFrom( + this.http.get('/api/pathways') + ).catch((error) => { + this.catalogRequest = undefined; + throw error; + }); + return this.catalogRequest; + } + + private parse(pathway: DeltaSignalPathway) { + return firstValueFrom( + this.http.post('/api/parse', { pathway_id: pathway.id }) + ); + } + + private requestSolve(networkId: string, observations: Record) { + return firstValueFrom( + this.http.post('/api/solve', { + network_id: networkId, + observations, + }) + ); + } + + private errorMessage(error: unknown, fallback: string) { + if (error instanceof HttpErrorResponse) { + return typeof error.error?.message === 'string' ? error.error.message : fallback; + } + return error instanceof Error ? error.message : fallback; + } +} diff --git a/projects/pathway-browser/src/app/deltasignal/deltasignal.utils.spec.ts b/projects/pathway-browser/src/app/deltasignal/deltasignal.utils.spec.ts new file mode 100644 index 00000000..0a0c597d --- /dev/null +++ b/projects/pathway-browser/src/app/deltasignal/deltasignal.utils.spec.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { + aggregateRowsByReactomeId, + buildObservations, + groupChangesByReactomeId, + resultRows, +} from './deltasignal.utils'; +import type { + DeltaSignalNetwork, + DeltaSignalPerturbation, + DeltaSignalSolveResult, +} from './deltasignal.model'; + +const network: DeltaSignalNetwork = { + status: 'success', + message: 'ok', + network_id: 'pw:test', + edges: [], + pathways: [], + nodes: [ + { + uuid: 'a', + name: 'ATM one', + reactome_id: 'R-HSA-1', + entity_type: 'protein', + baseline: 0.01, + set_id: null, + }, + { + uuid: 'b', + name: 'ATM two', + reactome_id: 'R-HSA-1', + entity_type: 'protein', + baseline: 0.01, + set_id: null, + }, + ], +}; + +const perturbation: DeltaSignalPerturbation = { + reactomeId: 'R-HSA-1', + name: 'ATM', + activity: 80, + nodeUuids: ['a', 'b'], +}; + +const result: DeltaSignalSolveResult = { + status: 'success', + message: 'ok', + node_activities: { a: 0.8, b: 0.25 }, + influence_scores: { a: 2, b: 5 }, + converged: true, + iterations: 3, + solve_time: 0.01, +}; + +describe('DeltaSignal result mapping', () => { + it('applies one entity perturbation to every matching logic-network UUID', () => { + expect(buildObservations([perturbation])).toEqual({ + a: [80, 1], + b: [80, 1], + }); + }); + + it('converts solver output to the UI scale and ranks by influence', () => { + const rows = resultRows(network, result, [perturbation]); + + expect(rows.map((row) => row.uuid)).toEqual(['b', 'a']); + expect(rows[0]).toMatchObject({ baseline: 1, activity: 25, change: 24, perturbed: true }); + }); + + it('preserves all changes when several network nodes map to one diagram entity', () => { + const grouped = groupChangesByReactomeId(resultRows(network, result, [perturbation])); + + expect(grouped.get('R-HSA-1')).toEqual([24, 79]); + }); + + it('collapses internal duplicates to one Reactome entity in the result table', () => { + const aggregated = aggregateRowsByReactomeId(resultRows(network, result, [perturbation])); + + expect(aggregated).toHaveLength(1); + expect(aggregated[0]).toMatchObject({ + reactomeId: 'R-HSA-1', + baseline: 1, + activity: 52.5, + change: 51.5, + influence: 5, + perturbed: true, + }); + }); +}); diff --git a/projects/pathway-browser/src/app/deltasignal/deltasignal.utils.ts b/projects/pathway-browser/src/app/deltasignal/deltasignal.utils.ts new file mode 100644 index 00000000..7bdd3811 --- /dev/null +++ b/projects/pathway-browser/src/app/deltasignal/deltasignal.utils.ts @@ -0,0 +1,84 @@ +import { + DeltaSignalNetwork, + DeltaSignalPerturbation, + DeltaSignalResultRow, + DeltaSignalSolveResult, +} from './deltasignal.model'; + +export const toDisplayActivity = (activity: number) => activity * 100; + +export function buildObservations( + perturbations: Iterable +): Record { + return Object.fromEntries( + [...perturbations].flatMap((perturbation) => + perturbation.nodeUuids.map((uuid) => [uuid, [perturbation.activity, 1] as [number, number]]) + ) + ); +} + +export function resultRows( + network: DeltaSignalNetwork | null, + result: DeltaSignalSolveResult | null, + perturbations: Iterable +): DeltaSignalResultRow[] { + if (!network || !result) return []; + + const perturbedUuids = new Set([...perturbations].flatMap((item) => item.nodeUuids)); + return network.nodes + .filter((node) => result.node_activities[node.uuid] !== undefined) + .map((node) => { + const baseline = toDisplayActivity(node.baseline); + const activity = toDisplayActivity(result.node_activities[node.uuid]); + return { + uuid: node.uuid, + reactomeId: node.reactome_id, + name: node.name, + baseline, + activity, + change: activity - baseline, + influence: result.influence_scores[node.uuid] ?? 0, + perturbed: perturbedUuids.has(node.uuid), + }; + }) + .sort((a, b) => b.influence - a.influence || Math.abs(b.change) - Math.abs(a.change)); +} + +/** + * A Reactome diagram entity can expand to several logic-network nodes. Keep + * their individual changes so sets can be rendered as a multi-colour gradient + * rather than silently choosing one member. + */ +export function groupChangesByReactomeId(rows: DeltaSignalResultRow[]): Map { + const grouped = new Map(); + for (const row of rows) { + if (!row.reactomeId) continue; + const values = grouped.get(row.reactomeId) ?? []; + values.push(row.change); + grouped.set(row.reactomeId, values); + } + return grouped; +} + +/** Collapse internal solver duplicates for a readable one-entity-per-row table. */ +export function aggregateRowsByReactomeId(rows: DeltaSignalResultRow[]): DeltaSignalResultRow[] { + const grouped = new Map(); + for (const row of rows) { + if (!row.reactomeId) continue; + grouped.set(row.reactomeId, [...(grouped.get(row.reactomeId) ?? []), row]); + } + + const mean = (values: number[]) => values.reduce((sum, value) => sum + value, 0) / values.length; + return [...grouped.entries()] + .map(([reactomeId, members]) => ({ + uuid: reactomeId, + reactomeId, + name: [...new Set(members.map((member) => member.name))].join(' / '), + baseline: mean(members.map((member) => member.baseline)), + activity: mean(members.map((member) => member.activity)), + change: mean(members.map((member) => member.change)), + influence: Math.max(...members.map((member) => member.influence)), + perturbed: members.some((member) => member.perturbed), + })) + .sort((a, b) => Math.abs(b.change) - Math.abs(a.change) || b.influence - a.influence); +} diff --git a/projects/pathway-browser/src/app/diagram/diagram.component.ts b/projects/pathway-browser/src/app/diagram/diagram.component.ts index 7cdb32f8..0cfca9ad 100644 --- a/projects/pathway-browser/src/app/diagram/diagram.component.ts +++ b/projects/pathway-browser/src/app/diagram/diagram.component.ts @@ -67,6 +67,7 @@ import { } from './entity-popup/entity-popup.component'; import { IS_CURATOR } from '../../environments/environment'; import { FlagBannerComponent } from './flag-banner/flag-banner.component'; +import { DeltaSignalService } from '../deltasignal/deltasignal.service'; const INIT_RX = 2; @@ -107,6 +108,7 @@ export class DiagramComponent implements AfterViewInit, OnDestroy { private route = inject(ActivatedRoute); private download = inject(DownloadService); private data = inject(DataStateService); + private deltaSignal = inject(DeltaSignalService); title = 'pathway-browser'; @ViewChild('cytoscape') cytoscapeContainer?: ElementRef; @@ -184,6 +186,11 @@ export class DiagramComponent implements AfterViewInit, OnDestroy { this.dark.isDark(); this.updateStyle(); }); + effect(() => { + const overlay = this.deltaSignal.overlay(); + const palette = this.deltaSignal.palette(); + this.avoidSideEffect(() => this.applyDeltaSignalOverlay(overlay, palette)); + }); effect(() => { const request = this.download.downloadRequest(); @@ -1258,7 +1265,8 @@ export class DiagramComponent implements AfterViewInit, OnDestroy { if (!overrideIgnore) this.syncing = false; }; - private loadAnalysis(token: string | null) { + private loadAnalysis(token: string | null, force = false) { + if (!force && this.deltaSignal.hasOverlay()) return; const diagramId = this.pathwayId(); if (!token || !diagramId) { this._loadAnalysisFn = undefined; @@ -1293,6 +1301,7 @@ export class DiagramComponent implements AfterViewInit, OnDestroy { result: this.analysis.result$.pipe(filter(isDefined), take(1)), }).subscribe(({ entities, pathways, result }) => { this._loadAnalysisFn = (analysisIndex) => { + if (this.deltaSignal.hasOverlay()) return; const analysisEntityMap = new Map( entities.entities.flatMap((entity) => entity.mapsTo @@ -1366,6 +1375,38 @@ export class DiagramComponent implements AfterViewInit, OnDestroy { private _loadAnalysisFn: ((analysisIndex: number) => void) | undefined; + private applyDeltaSignalOverlay( + overlay: Map, + palette: ReturnType + ) { + if (!this.cy) return; + if (!overlay.size) { + this.loadAnalysis(this.state.analysis(), true); + return; + } + + this.cys.filter(Boolean).forEach((cy) => { + cy.batch(() => { + cy.nodes('.PhysicalEntity').forEach((node) => { + const graph = node.data('graph') as Graph.Node | undefined; + const leaves: Graph.Node[] = node.data('graph.leaves') || (graph ? [graph] : []); + const stableIds = new Set([ + node.data('graph.stId') as string, + ...leaves.flatMap((leaf) => [leaf.stId, leaf.identifier, leaf.standardIdentifier]), + ]); + const values = [...stableIds] + .filter(isDefined) + .flatMap((stableId) => overlay.get(stableId) ?? []); + node.data('exp', values.length ? values : [undefined]); + }); + cy.nodes('.Pathway, .InteractorOccurrences').data('exp', [undefined]); + const style: Style = cy.data('reactome'); + style.loadAnalysis(cy, palette); + }); + }); + setTimeout(() => this.thumbnailImg.set(this.cy.png({ full: true, maxHeight: 240 })), 5); + } + updateStyle() { this.cy ? setTimeout(() => { @@ -1459,6 +1500,9 @@ export class DiagramComponent implements AfterViewInit, OnDestroy { } this.loadAnalysis(this.state.analysis()); + if (this.deltaSignal.hasOverlay()) { + this.applyDeltaSignalOverlay(this.deltaSignal.overlay(), this.deltaSignal.palette()); + } } compareBackgroundSync = this.reactomeEvents$ diff --git a/projects/pathway-browser/src/app/viewport/viewport.component.html b/projects/pathway-browser/src/app/viewport/viewport.component.html index 3a8b0ae4..9b269898 100644 --- a/projects/pathway-browser/src/app/viewport/viewport.component.html +++ b/projects/pathway-browser/src/app/viewport/viewport.component.html @@ -76,6 +76,20 @@ } + } @if (hasMultipleProfile()) { @@ -245,6 +259,15 @@ + @let deltaSignalStatus = dropdown() === 'deltasignal' ? 'open' : 'closed'; +
(null); + dropdown = signal<'analysis' | 'compare' | 'deltasignal' | null>(null); toggleAnalysis() { this.dropdown.set(this.dropdown() ? null : 'analysis'); if (this.dropdown() !== 'analysis') this.closeAnalysis(); } + toggleDeltaSignal() { + this.dropdown.update((open) => (open === 'deltasignal' ? null : 'deltasignal')); + } + closeAnalysis() { this.dropdown.set(null); if (this.state.analysisTab()) this.state.analysisTab.set(null); diff --git a/proxy.conf.js b/proxy.conf.js index 7d8048a6..c3b72807 100644 --- a/proxy.conf.js +++ b/proxy.conf.js @@ -13,10 +13,16 @@ */ const backend = process.env.REACTOME_BACKEND || 'http://localhost:8080'; const secure = backend.startsWith('https'); +const deltaSignalBackend = process.env.DELTASIGNAL_BACKEND || 'http://localhost:8080'; const localService = (context) => [context, { target: backend, secure, changeOrigin: true }]; module.exports = { + '/api': { + target: deltaSignalBackend, + secure: deltaSignalBackend.startsWith('https'), + changeOrigin: true, + }, '/reactome': { target: 'https://download.reactome.org', secure: true, From cef689b4bcbbef5610029e320d4fa08241cbecab Mon Sep 17 00:00:00 2001 From: Chryseis Liu <130321313+Chryseisliu@users.noreply.github.com> Date: Thu, 20 Aug 2026 01:17:48 -0700 Subject: [PATCH 2/2] Fix DeltaSignal dead-code check --- .../pathway-browser/src/app/deltasignal/deltasignal.model.ts | 4 ++-- .../pathway-browser/src/app/deltasignal/deltasignal.utils.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/projects/pathway-browser/src/app/deltasignal/deltasignal.model.ts b/projects/pathway-browser/src/app/deltasignal/deltasignal.model.ts index 14f07903..6a189b89 100644 --- a/projects/pathway-browser/src/app/deltasignal/deltasignal.model.ts +++ b/projects/pathway-browser/src/app/deltasignal/deltasignal.model.ts @@ -4,7 +4,7 @@ export interface DeltaSignalPathway { name: string; } -export interface DeltaSignalNode { +interface DeltaSignalNode { uuid: string; name: string; reactome_id: string; @@ -13,7 +13,7 @@ export interface DeltaSignalNode { set_id: string | null; } -export interface DeltaSignalEdge { +interface DeltaSignalEdge { parent_uuid: string; child_uuid: string; is_and: boolean; diff --git a/projects/pathway-browser/src/app/deltasignal/deltasignal.utils.ts b/projects/pathway-browser/src/app/deltasignal/deltasignal.utils.ts index 7bdd3811..753dd510 100644 --- a/projects/pathway-browser/src/app/deltasignal/deltasignal.utils.ts +++ b/projects/pathway-browser/src/app/deltasignal/deltasignal.utils.ts @@ -5,7 +5,7 @@ import { DeltaSignalSolveResult, } from './deltasignal.model'; -export const toDisplayActivity = (activity: number) => activity * 100; +const toDisplayActivity = (activity: number) => activity * 100; export function buildObservations( perturbations: Iterable