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 @@
+
+
+@if (service.status() === 'loading') {
+
+
+ Preparing the pathway network…
+
+} @else if (service.error() && !service.network()) {
+
+
error_outline
+
+ Network unavailable
+ {{ service.error() }}
+
+
+} @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.
+ }
+
+
+
+ }
+
+
+
+
Input activity
+
+
+ × baseline
+
+
+
+
+ Knock out · 0
+ Baseline · 1
+ Activate · 80
+
+
+ add
+ Add perturbation
+
+
+
+
+
+
+
+ 2
+
Run prediction
+
+ @if (service.perturbations().length) {
+
Clear all
+ }
+
+
+ @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' }}
+
+
+ delete_outline
+
+
+ }
+
+
+ @if (service.status() === 'solving') {
+ Solving…
+ } @else {
+ play_arrow Run DeltaSignal
+ }
+
+ } @else {
+ Add at least one perturbation to run a prediction.
+ }
+
+ @if (service.error() && service.network()) {
+ error_outline {{ service.error() }}
+ }
+
+
+ @if (service.result(); as result) {
+
+
+
+ 3
+
Predicted response
+
+
Remove overlay
+
+
+
+
+ {{ 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.
+
+
+
+
+ @for (row of visibleRows(); track row.uuid) {
+
+
+ {{ row.name }}
+ {{ row.reactomeId }}
+
+ {{ row.activity | number: '1.1-1' }}×
+ 0" [class.negative]="row.change < 0">
+ {{ 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..6a189b89
--- /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;
+}
+
+interface DeltaSignalNode {
+ uuid: string;
+ name: string;
+ reactome_id: string;
+ entity_type: string;
+ baseline: number;
+ set_id: string | null;
+}
+
+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..753dd510
--- /dev/null
+++ b/projects/pathway-browser/src/app/deltasignal/deltasignal.utils.ts
@@ -0,0 +1,84 @@
+import {
+ DeltaSignalNetwork,
+ DeltaSignalPerturbation,
+ DeltaSignalResultRow,
+ DeltaSignalSolveResult,
+} from './deltasignal.model';
+
+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 @@
}
+
+ conversion_path
+ Perturb
+
}
@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,