diff --git a/documentation/PathwayBrowser/deltasignal.md b/documentation/PathwayBrowser/deltasignal.md
new file mode 100644
index 00000000..dde79294
--- /dev/null
+++ b/documentation/PathwayBrowser/deltasignal.md
@@ -0,0 +1,193 @@
+# DeltaSignal pathway perturbation prototype
+
+This prototype connects the Reactome Pathway Browser to the DeltaSignal
+steady-state solver. A user can change one or more molecules, run a prediction,
+and inspect the response in two complementary views:
+
+1. the familiar Reactome diagram, coloured by predicted change; and
+2. the underlying LNG logic nodes and edges, where position-aware UUIDs remain
+ separate.
+
+The feature is an interface to an existing model. It does not change
+DeltaSignal's propagation rules, and the visualization is not evidence that a
+prediction is biologically correct.
+
+
+
+## Run a prediction
+
+1. Open a pathway for which the DeltaSignal API has a generated LNG network.
+2. Choose **Perturb** in the Pathway Browser toolbar.
+3. Select a physical entity on the diagram, not an event in the pathway tree.
+4. Set its activity and choose **Add perturbation**.
+5. Repeat the selection step if the prediction needs more than one input.
+6. Choose **Run DeltaSignal**.
+7. Check the convergence message before interpreting the result.
+8. Use **Reactome diagram** or **Logic nodes & edges** to change the result
+ view.
+
+The panel is a side drawer, so the pathway diagram remains visible and
+interactive while the controls are open. On a narrow screen the drawer expands
+to the full viewport.
+
+If the panel says that an entity is not represented, the selected Reactome
+stable identifier has no matching node in the parsed LNG network. The UI does
+not substitute another molecule or silently drop the selection.
+
+## Activity scale
+
+The input control uses DeltaSignal's activity scale:
+
+| Value | Meaning |
+| ---------------: | ----------------------------- |
+| `0` | knockout or no input activity |
+| `1` | normal baseline activity |
+| greater than `1` | activation above baseline |
+
+The quick activation preset is `80`, which is intentionally a strong
+perturbation. The number is a relative model input, not a measured expression
+fold change. It should not be described as 80-fold RNA expression.
+
+The solver returns internal activities on a 0 to 1 scale. The client multiplies
+them by 100 for display, so a displayed baseline of `100x` corresponds to the
+internal baseline value `1.0`.
+
+## Reactome diagram view
+
+Diagram colour shows the predicted change from each logic node's own baseline:
+
+- blue means lower activity;
+- near-white means little change;
+- red means higher activity.
+
+A Reactome entity can expand into several LNG logic nodes. This occurs when the
+same stable identifier has different positional or logical roles in the
+generated network. The diagram keeps every member value and renders a gradient
+when those values differ. The accompanying result table is intentionally more
+compact: it shows one row per Reactome stable identifier, uses the mean member
+activity and change, and keeps the largest member influence score.
+
+The table is limited to the 40 largest absolute changes. This is a display
+limit, not a solver limit. The complete result remains available in the API
+response.
+
+## Logic nodes and edges view
+
+
+
+This view exposes the graph that DeltaSignal actually solved. It is useful for
+checking how Reactome entities were expanded by LNG and for following a
+prediction through reactions, complexes, sets, and sequence entities.
+
+The graph contains the 40 UUID-level nodes with the largest absolute predicted
+changes, plus every perturbed input even if it falls outside that limit. An
+edge is drawn only when both endpoints are visible. The view therefore never
+draws an apparent direct connection through an omitted node.
+
+### Reading the graph
+
+- Node colour uses the same lower/neutral/higher scale as the diagram overlay.
+- Node size increases with the absolute influence score.
+- A thick amber border marks a perturbed input.
+- Diamonds are reactions.
+- Rounded rectangles are complexes or sets.
+- Hexagons are DNA or RNA sequence entities.
+- Teal arrows are positive edges.
+- Magenta arrows are negative edges.
+- Solid edges are AND relationships.
+- Dashed edges are OR relationships.
+
+Selecting a node opens its Reactome stable identifier, exact LNG UUID, entity
+kind, baseline, predicted activity, change, influence, and mapping
+multiplicity. Mapping multiplicity is the number of UUIDs in the solved network
+that share that Reactome stable identifier.
+
+The layout is an interactive Cytoscape view. Users can pan, zoom, select nodes,
+and use the fit button to restore the full subgraph.
+
+## API contract
+
+The Angular client calls the DeltaSignal API through the development or
+production proxy:
+
+- `GET /api/pathways` lists available generated pathways;
+- `POST /api/parse { pathway_id }` parses one network and returns nodes, edges,
+ and mappings;
+- `POST /api/solve { network_id, observations }` returns activities, influence
+ scores, convergence status, iteration count, and solve time.
+
+When one selected Reactome entity maps to several UUIDs, the same observation
+is sent to every matching UUID. The UUIDs are not merged before solving.
+
+## Local development
+
+Clone the three repositories in any convenient locations:
+
+```sh
+git clone git@github.com:reactome/WebsiteAngular.git
+git clone git@github.com:reactome/deltasignal.git
+git clone git@github.com:reactome/logic-network-generator.git
+```
+
+Generate or obtain an LNG catalog containing one directory per pathway. Each
+pathway directory must contain `logic_network.csv` and
+`stid_to_uuid_mapping.csv`.
+
+Start the DeltaSignal API:
+
+```sh
+DS_PATHWAY_CATALOG=/path/to/lng/output \
+ julia --project=/path/to/deltasignal \
+ /path/to/deltasignal/src/api/server.jl \
+ --host=127.0.0.1 --port=8080
+```
+
+Then start the Pathway Browser:
+
+```sh
+REACTOME_BACKEND=https://reactome.org \
+DELTASIGNAL_BACKEND=http://127.0.0.1:8080 \
+npm run start:simple -- --host 127.0.0.1
+```
+
+Open a pathway available in the generated catalog. For example:
+
+```text
+http://127.0.0.1:4200/PathwayBrowser/R-HSA-69620
+```
+
+The Angular development proxy sends `/api` to `DELTASIGNAL_BACKEND`. If this
+variable is omitted, it defaults to `http://localhost:8080`.
+
+## Verification
+
+The graph-selection and truncation behavior is covered by focused unit tests:
+
+```sh
+npx vitest run \
+ projects/pathway-browser/src/app/deltasignal/deltasignal.utils.spec.ts
+```
+
+The complete development build is checked with:
+
+```sh
+npx ng build --configuration development
+```
+
+The manual browser check used Cell Cycle Checkpoints (`R-HSA-69620`), selected
+TP53, set activity to `0`, ran DeltaSignal, inspected the Reactome overlay, and
+then selected the TP53 UUID in the logic graph. The run converged in 175
+iterations and the graph displayed 40 nodes and 40 retained edges.
+
+## Current boundary
+
+The prototype solves and visualizes one generated pathway at a time. It does
+not yet join neighboring pathway networks or calculate one defensible activity
+score for every ReacFoam region. A ReacFoam overlay would first require an
+explicit cross-pathway aggregation rule and a catalog broad enough to support
+it. Adding coloured regions before those choices are defined would make the UI
+look more complete than the model underneath it.
+
+The current nodes-and-edges view was therefore implemented first. It exposes
+the exact graph already returned by DeltaSignal and gives a direct way to audit
+LNG expansion, edge signs, logic relationships, and UUID mappings.
diff --git a/documentation/PathwayBrowser/images/deltasignal-diagram-overlay.png b/documentation/PathwayBrowser/images/deltasignal-diagram-overlay.png
new file mode 100644
index 00000000..8426b140
Binary files /dev/null and b/documentation/PathwayBrowser/images/deltasignal-diagram-overlay.png differ
diff --git a/documentation/PathwayBrowser/images/deltasignal-logic-network.png b/documentation/PathwayBrowser/images/deltasignal-logic-network.png
new file mode 100644
index 00000000..55913be0
Binary files /dev/null and b/documentation/PathwayBrowser/images/deltasignal-logic-network.png differ
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-logic-graph.component.html b/projects/pathway-browser/src/app/deltasignal/deltasignal-logic-graph.component.html
new file mode 100644
index 00000000..de157d9e
--- /dev/null
+++ b/projects/pathway-browser/src/app/deltasignal/deltasignal-logic-graph.component.html
@@ -0,0 +1,72 @@
+
+
+
+ Lower activity
+ Near baseline
+ Higher activity
+ Positive edge
+ Negative edge
+
+
+
+
+
+ @if (selected(); as node) {
+
+ Selected logic node
+ {{ node.name }}
+
+
+
Reactome ID
+ {{ node.reactomeId }}
+
+
+
Logic UUID
+ {{ node.uuid }}
+
+
+
Kind
+ {{ node.entityType }}
+
+
+
Baseline
+ {{ node.baseline | number: '1.1-1' }}×
+
+
+
Predicted
+ {{ node.activity | number: '1.1-1' }}×
+
+
+
Change
+ {{ node.change > 0 ? '+' : '' }}{{ node.change | number: '1.1-1' }}
+
+
+
Influence
+ {{ node.influence | number: '1.2-2' }}
+
+
+
Mapping
+ {{ node.mappingMultiplicity }} UUID{{ node.mappingMultiplicity === 1 ? '' : 's' }} for this entity
+
+
+
+ }
+
+
+
+ Each shape is one LNG UUID. The view keeps the 40 largest absolute changes plus any perturbed inputs; an edge is drawn
+ only when both of its endpoints are visible.
+
diff --git a/projects/pathway-browser/src/app/deltasignal/deltasignal-logic-graph.component.scss b/projects/pathway-browser/src/app/deltasignal/deltasignal-logic-graph.component.scss
new file mode 100644
index 00000000..16e6e85d
--- /dev/null
+++ b/projects/pathway-browser/src/app/deltasignal/deltasignal-logic-graph.component.scss
@@ -0,0 +1,147 @@
+:host {
+ display: block;
+}
+
+.logic-toolbar,
+.logic-toolbar > div,
+.logic-legend,
+.logic-legend span {
+ display: flex;
+ align-items: center;
+}
+
+.logic-toolbar {
+ justify-content: space-between;
+ gap: 16px;
+ margin: 18px 0 10px;
+
+ > div {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ span {
+ color: var(--on-surface-variant);
+ font: var(--mat-sys-label-small);
+ }
+}
+
+.logic-legend {
+ flex-wrap: wrap;
+ gap: 8px 16px;
+ margin-bottom: 10px;
+ color: var(--on-surface-variant);
+ font: var(--mat-sys-label-small);
+
+ span {
+ gap: 6px;
+ }
+}
+
+.swatch {
+ width: 12px;
+ height: 12px;
+ border: 1px solid var(--outline-variant);
+ border-radius: 50%;
+
+ &.lower {
+ background: #a23b72;
+ }
+
+ &.neutral {
+ background: #f4f1ed;
+ }
+
+ &.higher {
+ background: #1b7f8c;
+ }
+}
+
+.line {
+ width: 18px;
+ border-top: 2px solid;
+
+ &.positive {
+ border-color: #1b7f8c;
+ }
+
+ &.negative {
+ border-color: #a23b72;
+ }
+}
+
+.graph-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(220px, 280px);
+ min-height: 500px;
+ overflow: hidden;
+ border: 1px solid var(--outline-variant);
+ border-radius: 8px;
+ background: var(--surface);
+}
+
+.logic-graph {
+ min-width: 0;
+ min-height: 500px;
+ background: color-mix(in srgb, var(--surface-container) 35%, var(--surface));
+}
+
+.node-details {
+ padding: 18px;
+ overflow: auto;
+ border-left: 1px solid var(--outline-variant);
+ background: var(--surface-container-low);
+
+ > strong {
+ display: block;
+ margin: 4px 0 14px;
+ }
+
+ dl,
+ dl div {
+ margin: 0;
+ }
+
+ dl div {
+ display: grid;
+ grid-template-columns: 78px minmax(0, 1fr);
+ gap: 8px;
+ padding: 7px 0;
+ border-bottom: 1px solid var(--outline-variant);
+ }
+
+ dt {
+ color: var(--on-surface-variant);
+ font: var(--mat-sys-label-small);
+ }
+
+ dd {
+ overflow-wrap: anywhere;
+ font: var(--mat-sys-body-small);
+ }
+
+ .uuid {
+ font-family: monospace;
+ }
+}
+
+.detail-label,
+.graph-note {
+ color: var(--on-surface-variant);
+ font: var(--mat-sys-body-small);
+}
+
+.graph-note {
+ margin: 8px 0 0;
+}
+
+@media (max-width: 760px) {
+ .graph-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .node-details {
+ border-top: 1px solid var(--outline-variant);
+ border-left: 0;
+ }
+}
diff --git a/projects/pathway-browser/src/app/deltasignal/deltasignal-logic-graph.component.ts b/projects/pathway-browser/src/app/deltasignal/deltasignal-logic-graph.component.ts
new file mode 100644
index 00000000..5e2171b9
--- /dev/null
+++ b/projects/pathway-browser/src/app/deltasignal/deltasignal-logic-graph.component.ts
@@ -0,0 +1,221 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ effect,
+ ElementRef,
+ inject,
+ NgZone,
+ OnDestroy,
+ signal,
+ untracked,
+ viewChild,
+} from '@angular/core';
+import { DecimalPipe } from '@angular/common';
+import { MatIcon } from '@angular/material/icon';
+import { MatIconButton } from '@angular/material/button';
+import { MatTooltip } from '@angular/material/tooltip';
+import cytoscape, { Core, ElementDefinition, StylesheetJson } from 'cytoscape';
+import chroma from 'chroma-js';
+import { DeltaSignalService } from './deltasignal.service';
+import { buildLogicGraph, DeltaSignalLogicGraphNode } from './deltasignal.utils';
+
+const GRAPH_LIMIT = 40;
+
+@Component({
+ selector: 'cr-deltasignal-logic-graph',
+ templateUrl: './deltasignal-logic-graph.component.html',
+ styleUrl: './deltasignal-logic-graph.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [DecimalPipe, MatIcon, MatIconButton, MatTooltip],
+})
+export class DeltaSignalLogicGraphComponent implements OnDestroy {
+ private readonly service = inject(DeltaSignalService);
+ private readonly zone = inject(NgZone);
+ private readonly container = viewChild>('graphContainer');
+ private cy?: Core;
+ private observedElement?: HTMLDivElement;
+
+ readonly subgraph = computed(() =>
+ buildLogicGraph(this.service.network(), this.service.rows(), GRAPH_LIMIT)
+ );
+ readonly selected = signal(null);
+
+ private readonly resizeObserver = new ResizeObserver(() => {
+ this.cy?.resize();
+ this.cy?.fit(undefined, 28);
+ });
+
+ constructor() {
+ effect(() => {
+ const container = this.container()?.nativeElement;
+ const graph = this.subgraph();
+ const palette = this.service.palette();
+ if (!container) return;
+ untracked(() => this.render(container, graph.nodes, graph.edges, palette));
+ });
+ }
+
+ fit() {
+ this.cy?.fit(undefined, 28);
+ }
+
+ ngOnDestroy(): void {
+ this.resizeObserver.disconnect();
+ this.cy?.destroy();
+ }
+
+ private render(
+ container: HTMLDivElement,
+ nodes: DeltaSignalLogicGraphNode[],
+ edges: ReturnType['edges'],
+ palette: ReturnType
+ ) {
+ this.cy?.destroy();
+ if (this.observedElement !== container) {
+ this.resizeObserver.disconnect();
+ this.resizeObserver.observe(container);
+ this.observedElement = container;
+ }
+
+ const maxInfluence = Math.max(1, ...nodes.map((node) => Math.abs(node.influence)));
+ const graphElements: ElementDefinition[] = [
+ ...nodes.map((node) => {
+ const fill = palette(node.change).hex();
+ return {
+ group: 'nodes' as const,
+ classes: `${nodeClass(node.entityType)}${node.perturbed ? ' perturbed' : ''}`,
+ data: {
+ id: node.uuid,
+ label: compactLabel(node.name),
+ color: fill,
+ labelColor: chroma(fill).get('oklch.l') > 0.68 ? '#172126' : '#ffffff',
+ size: 42 + 26 * Math.sqrt(Math.abs(node.influence) / maxInfluence),
+ },
+ };
+ }),
+ ...edges.map((edge, index) => ({
+ group: 'edges' as const,
+ classes: `${edge.is_positive ? 'positive' : 'negative'} ${edge.is_and ? 'and' : 'or'}`,
+ data: {
+ id: `edge-${index}-${edge.parent_uuid}-${edge.child_uuid}`,
+ source: edge.parent_uuid,
+ target: edge.child_uuid,
+ edgeType: edge.edge_type,
+ },
+ })),
+ ];
+
+ this.cy = cytoscape({
+ container,
+ elements: graphElements,
+ minZoom: 0.15,
+ maxZoom: 3,
+ wheelSensitivity: 0.2,
+ style: logicGraphStyles,
+ });
+ this.cy.on('tap', 'node', (event) => {
+ const selected = nodes.find((node) => node.uuid === event.target.id()) ?? null;
+ this.zone.run(() => this.selected.set(selected));
+ });
+ this.cy
+ .layout({
+ name: 'cose',
+ animate: false,
+ fit: true,
+ padding: 36,
+ nodeRepulsion: 7000,
+ idealEdgeLength: 90,
+ })
+ .run();
+
+ const initial = nodes.find((node) => node.perturbed) ?? nodes[0] ?? null;
+ this.selected.set(initial);
+ if (initial) this.cy.getElementById(initial.uuid).select();
+ }
+}
+
+const logicGraphStyles: StylesheetJson = [
+ {
+ selector: 'node',
+ style: {
+ 'background-color': 'data(color)',
+ 'border-color': '#0b7285',
+ 'border-width': 1.5,
+ color: 'data(labelColor)',
+ height: 'data(size)',
+ label: 'data(label)',
+ shape: 'ellipse',
+ 'font-size': 10,
+ 'font-weight': 600,
+ 'min-zoomed-font-size': 7,
+ 'text-halign': 'center',
+ 'text-valign': 'center',
+ 'text-max-width': '74px',
+ 'text-wrap': 'ellipsis',
+ width: 'data(size)',
+ },
+ },
+ {
+ selector: 'node.perturbed',
+ style: {
+ 'border-color': '#e89b1d',
+ 'border-width': 5,
+ },
+ },
+ {
+ selector: 'node.reaction',
+ style: { shape: 'diamond' },
+ },
+ {
+ selector: 'node.complex',
+ style: { shape: 'round-rectangle' },
+ },
+ {
+ selector: 'node.sequence',
+ style: { shape: 'hexagon' },
+ },
+ {
+ selector: 'node:selected',
+ style: {
+ 'border-color': '#11181c',
+ 'border-width': 4,
+ },
+ },
+ {
+ selector: 'edge',
+ style: {
+ 'curve-style': 'bezier',
+ 'line-color': '#1b7f8c',
+ 'line-style': 'solid',
+ opacity: 0.72,
+ 'target-arrow-color': '#1b7f8c',
+ 'target-arrow-shape': 'triangle',
+ width: 2,
+ },
+ },
+ {
+ selector: 'edge.negative',
+ style: {
+ 'line-color': '#a23b72',
+ 'target-arrow-color': '#a23b72',
+ },
+ },
+ {
+ selector: 'edge.or',
+ style: { 'line-style': 'dashed' },
+ },
+];
+
+function compactLabel(name: string): string {
+ const compartment = name.indexOf(' [');
+ return (compartment >= 0 ? name.slice(0, compartment) : name).slice(0, 42);
+}
+
+function nodeClass(entityType: string): string {
+ const normalized = entityType.toLowerCase();
+ if (normalized.includes('reaction')) return 'reaction';
+ if (normalized.includes('complex') || normalized.includes('set')) return 'complex';
+ if (normalized.includes('rna') || normalized.includes('dna')) return 'sequence';
+ return 'entity';
+}
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..06fd1279
--- /dev/null
+++ b/projects/pathway-browser/src/app/deltasignal/deltasignal-panel.component.html
@@ -0,0 +1,238 @@
+
+
+@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
+
+
+
+
+ account_tree
+ Reactome diagram
+
+
+ hub
+ Logic nodes & edges
+
+
+
+ @if (resultView() === 'diagram') {
+
+
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. The pathway diagram remains interactive beside this panel.
+
+
+
+
+ @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.
+
+ }
+ } @else {
+
+ }
+
+ }
+
+}
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..b6f712cf
--- /dev/null
+++ b/projects/pathway-browser/src/app/deltasignal/deltasignal-panel.component.scss
@@ -0,0 +1,447 @@
+:host {
+ display: block;
+ height: 100%;
+ overflow: auto;
+ container-type: inline-size;
+ 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);
+ }
+}
+
+.view-toggle {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ width: min(100%, 520px);
+ margin: 18px 0 4px;
+ padding: 3px;
+ border: 1px solid var(--outline-variant);
+ border-radius: 8px;
+ background: var(--surface-container-high);
+
+ button {
+ display: flex;
+ min-height: 40px;
+ align-items: center;
+ justify-content: center;
+ gap: 7px;
+ padding: 7px 12px;
+ border: 0;
+ border-radius: 5px;
+ color: var(--on-surface-variant);
+ background: transparent;
+ cursor: pointer;
+ font: var(--mat-sys-label-large);
+
+ &.active {
+ color: var(--on-primary-container);
+ background: var(--primary-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;
+ }
+}
+
+@container (max-width: 760px) {
+ main {
+ grid-template-columns: 1fr;
+ padding: 18px;
+ }
+
+ .results {
+ grid-column: auto;
+ }
+}
+
+@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..598cb099
--- /dev/null
+++ b/projects/pathway-browser/src/app/deltasignal/deltasignal-panel.component.ts
@@ -0,0 +1,70 @@
+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';
+import { DeltaSignalLogicGraphComponent } from './deltasignal-logic-graph.component';
+
+@Component({
+ selector: 'cr-deltasignal-panel',
+ templateUrl: './deltasignal-panel.component.html',
+ styleUrl: './deltasignal-panel.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [
+ DecimalPipe,
+ DeltaSignalLogicGraphComponent,
+ 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 resultView = signal<'diagram' | 'logic'>('diagram');
+ 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..b00cc646
--- /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;
+}
+
+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..776fe216
--- /dev/null
+++ b/projects/pathway-browser/src/app/deltasignal/deltasignal.utils.spec.ts
@@ -0,0 +1,110 @@
+import { describe, expect, it } from 'vitest';
+import {
+ aggregateRowsByReactomeId,
+ buildLogicGraph,
+ 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: [
+ {
+ parent_uuid: 'a',
+ child_uuid: 'b',
+ is_and: true,
+ is_positive: false,
+ stoichiometry: 1,
+ edge_type: 'input',
+ },
+ ],
+ 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,
+ });
+ });
+
+ it('keeps logic UUIDs separate and retains edges between visible nodes', () => {
+ const graph = buildLogicGraph(network, resultRows(network, result, [perturbation]), 1);
+
+ // Both perturbed UUIDs remain visible even though the ordinary limit is one.
+ expect(graph.nodes.map((node) => node.uuid)).toEqual(['a', 'b']);
+ expect(graph.nodes.every((node) => node.mappingMultiplicity === 2)).toBe(true);
+ expect(graph.edges).toEqual([network.edges[0]]);
+ });
+});
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..9b9381b7
--- /dev/null
+++ b/projects/pathway-browser/src/app/deltasignal/deltasignal.utils.ts
@@ -0,0 +1,139 @@
+import {
+ DeltaSignalEdge,
+ DeltaSignalNetwork,
+ DeltaSignalPerturbation,
+ DeltaSignalResultRow,
+ DeltaSignalSolveResult,
+} from './deltasignal.model';
+
+const toDisplayActivity = (activity: number) => activity * 100;
+
+export interface DeltaSignalLogicGraphNode extends DeltaSignalResultRow {
+ entityType: string;
+ mappingMultiplicity: number;
+}
+
+export interface DeltaSignalLogicGraph {
+ nodes: DeltaSignalLogicGraphNode[];
+ edges: DeltaSignalEdge[];
+}
+
+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);
+}
+
+/**
+ * Keep the largest logic-node responses as separate UUIDs, including every
+ * perturbed node even when it falls outside the display limit. Edges are only
+ * retained when both endpoints are visible, so the graph never implies a
+ * connection through a node that has been omitted.
+ */
+export function buildLogicGraph(
+ network: DeltaSignalNetwork | null,
+ rows: DeltaSignalResultRow[],
+ limit = 40
+): DeltaSignalLogicGraph {
+ if (!network || !rows.length || limit < 1) return { nodes: [], edges: [] };
+
+ const ranked = [...rows].sort(
+ (a, b) => Math.abs(b.change) - Math.abs(a.change) || b.influence - a.influence
+ );
+ const selected = ranked.slice(0, limit);
+ const selectedIds = new Set(selected.map((row) => row.uuid));
+ for (const row of ranked) {
+ if (row.perturbed && !selectedIds.has(row.uuid)) {
+ selected.push(row);
+ selectedIds.add(row.uuid);
+ }
+ }
+
+ const networkNodes = new Map(network.nodes.map((node) => [node.uuid, node]));
+ const multiplicities = new Map();
+ for (const row of rows) {
+ if (!row.reactomeId) continue;
+ multiplicities.set(row.reactomeId, (multiplicities.get(row.reactomeId) ?? 0) + 1);
+ }
+
+ return {
+ nodes: selected.map((row) => ({
+ ...row,
+ entityType: networkNodes.get(row.uuid)?.entity_type ?? 'unknown',
+ mappingMultiplicity: multiplicities.get(row.reactomeId) ?? 1,
+ })),
+ edges: network.edges.filter(
+ (edge) => selectedIds.has(edge.parent_uuid) && selectedIds.has(edge.child_uuid)
+ ),
+ };
+}
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..5830bd89 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,