@datagrok/proteomics 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +18 -5
- package/dist/package-test.js +1 -1
- package/dist/package-test.js.map +1 -1
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/docs/personas-and-capabilities.md +16 -0
- package/docs/publishing-design-rationale.md +140 -0
- package/package.json +17 -3
- package/package.png +0 -0
- package/src/analysis/imputation.ts +3 -1
- package/src/analysis/normalization.ts +9 -4
- package/src/analysis/spc-storage.ts +1 -1
- package/src/menu.ts +5 -2
- package/src/package-api.ts +10 -2
- package/src/package-test.ts +3 -1
- package/src/package.g.ts +17 -3
- package/src/package.ts +31 -15
- package/src/panels/published-analysis-panel.ts +2 -2
- package/src/publishing/assert-published-shape.ts +2 -2
- package/src/publishing/package-settings-editor.ts +125 -0
- package/src/publishing/publish-project.ts +8 -8
- package/src/publishing/publish-settings.ts +57 -3
- package/src/publishing/publish-state.ts +45 -16
- package/src/publishing/reviewer-groups.ts +24 -0
- package/src/publishing/share-dialog.ts +42 -27
- package/src/publishing/trim-dataframe.ts +8 -1
- package/src/tests/abundance-correlation.ts +135 -0
- package/src/tests/analysis.ts +63 -0
- package/src/tests/enrichment-visualization.ts +95 -9
- package/src/tests/enrichment.ts +2 -1
- package/src/tests/project-vocabulary.ts +39 -0
- package/src/tests/publish-roundtrip.ts +40 -40
- package/src/tests/publish-spike.ts +2 -2
- package/src/tests/rank-abundance.ts +85 -0
- package/src/utils/abundance-detection.ts +145 -0
- package/src/viewers/abundance-correlation.ts +208 -0
- package/src/viewers/enrichment-viewers.ts +58 -24
- package/src/viewers/rank-abundance.ts +153 -0
- package/src/viewers/volcano.ts +3 -0
- package/test-console-output-1.log +1103 -1066
- package/test-record-1.mp4 +0 -0
- package/src/tests/group-mean-correlation.ts +0 -139
- package/src/viewers/group-mean-correlation.ts +0 -218
|
@@ -11,7 +11,7 @@ const SPIKE_TAG_KEYS = [
|
|
|
11
11
|
'proteomics.published',
|
|
12
12
|
'proteomics.published_at',
|
|
13
13
|
'proteomics.published_by',
|
|
14
|
-
'proteomics.
|
|
14
|
+
'proteomics.published_project',
|
|
15
15
|
'proteomics.published_de_method',
|
|
16
16
|
'proteomics.published_fc_threshold',
|
|
17
17
|
'proteomics.published_p_threshold',
|
|
@@ -78,7 +78,7 @@ function buildFixtureDataFrame(publishedId: string, nowIso: string): DG.DataFram
|
|
|
78
78
|
'proteomics.published': 'true',
|
|
79
79
|
'proteomics.published_at': nowIso,
|
|
80
80
|
'proteomics.published_by': 'spike-operator',
|
|
81
|
-
'proteomics.
|
|
81
|
+
'proteomics.published_project': 'SPIKE-PROJECT-A',
|
|
82
82
|
'proteomics.published_de_method': 'limma',
|
|
83
83
|
'proteomics.published_fc_threshold': '1.0',
|
|
84
84
|
'proteomics.published_p_threshold': '0.05',
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import * as grok from 'datagrok-api/grok';
|
|
2
|
+
import * as DG from 'datagrok-api/dg';
|
|
3
|
+
import {category, test, expect} from '@datagrok-libraries/test/src/test';
|
|
4
|
+
import {findAbundanceByCondition} from '../utils/abundance-detection';
|
|
5
|
+
import {dockRankAbundanceCharts} from '../viewers/rank-abundance';
|
|
6
|
+
import {setGroups} from '../analysis/experiment-setup';
|
|
7
|
+
import {SEMTYPE} from '../utils/proteomics-types';
|
|
8
|
+
|
|
9
|
+
/** Candidates-shaped mock: protein id + the two AVG Group Quantity columns +
|
|
10
|
+
* named groups (numerator / denominator). */
|
|
11
|
+
function makeCandidatesDf(num: number[], den: number[], g1 = 'DMD', g2 = 'WT'): DG.DataFrame {
|
|
12
|
+
const prot = num.map((_, i) => `P${i + 1}`);
|
|
13
|
+
const df = DG.DataFrame.fromColumns([
|
|
14
|
+
DG.Column.fromStrings('ProteinGroups', prot),
|
|
15
|
+
DG.Column.fromFloat32Array('AVG Group Quantity Numerator', new Float32Array(num)),
|
|
16
|
+
DG.Column.fromFloat32Array('AVG Group Quantity Denominator', new Float32Array(den)),
|
|
17
|
+
]);
|
|
18
|
+
df.col('ProteinGroups')!.semType = SEMTYPE.PROTEIN_ID;
|
|
19
|
+
setGroups(df, {group1: {name: g1, columns: []}, group2: {name: g2, columns: []}});
|
|
20
|
+
return df;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
category('Rank-Abundance', () => {
|
|
24
|
+
test('resolver reads per-condition abundance from Candidates group quantities', async () => {
|
|
25
|
+
const df = makeCandidatesDf([100, 1000, 10], [50, 500, 5]);
|
|
26
|
+
const ab = findAbundanceByCondition(df);
|
|
27
|
+
expect(ab !== null, true);
|
|
28
|
+
expect(ab!.group1.name, 'DMD');
|
|
29
|
+
expect(ab!.group2.name, 'WT');
|
|
30
|
+
// log10(100) = 2, log10(1000) = 3
|
|
31
|
+
expect(Math.abs((ab!.group1.log10Intensity[0] as number) - 2) < 1e-4, true);
|
|
32
|
+
expect(Math.abs((ab!.group1.log10Intensity[1] as number) - 3) < 1e-4, true);
|
|
33
|
+
expect(Math.abs((ab!.group2.log10Intensity[0] as number) - Math.log10(50)) < 1e-4, true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('resolver returns null with no abundance columns and no group columns', async () => {
|
|
37
|
+
const df = DG.DataFrame.fromColumns([
|
|
38
|
+
DG.Column.fromStrings('ProteinGroups', ['P1', 'P2']),
|
|
39
|
+
DG.Column.fromFloat32Array('log2FC', new Float32Array([1.0, -1.0])),
|
|
40
|
+
]);
|
|
41
|
+
expect(findAbundanceByCondition(df), null);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('resolver maps non-positive abundance to null', async () => {
|
|
45
|
+
const df = makeCandidatesDf([100, 0, -5], [10, 10, 10]);
|
|
46
|
+
const ab = findAbundanceByCondition(df)!;
|
|
47
|
+
expect(ab.group1.log10Intensity[1], null); // 0 → null
|
|
48
|
+
expect(ab.group1.log10Intensity[2], null); // negative → null
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('dockRankAbundanceCharts adds rank column (1 = highest abundance) and docks', async () => {
|
|
52
|
+
const df = makeCandidatesDf([100, 1000, 10], [50, 500, 5]);
|
|
53
|
+
df.name = 'Rank-abundance test';
|
|
54
|
+
const tv = grok.shell.addTableView(df);
|
|
55
|
+
try {
|
|
56
|
+
const docked = dockRankAbundanceCharts(tv, df);
|
|
57
|
+
expect(docked, true);
|
|
58
|
+
const rankCol = df.col('rank: DMD');
|
|
59
|
+
expect(rankCol !== null, true);
|
|
60
|
+
// abundances [100,1000,10] → highest (1000) is rank 1, 100 → rank 2, 10 → rank 3
|
|
61
|
+
expect(rankCol!.get(1), 1);
|
|
62
|
+
expect(rankCol!.get(0), 2);
|
|
63
|
+
expect(rankCol!.get(2), 3);
|
|
64
|
+
// two rank-abundance scatter plots docked
|
|
65
|
+
let scatters = 0;
|
|
66
|
+
for (const v of tv.viewers) if (v.type === DG.VIEWER.SCATTER_PLOT) scatters++;
|
|
67
|
+
expect(scatters, 2);
|
|
68
|
+
} finally {
|
|
69
|
+
tv.close();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('dockRankAbundanceCharts returns false when no abundance', async () => {
|
|
74
|
+
const df = DG.DataFrame.fromColumns([
|
|
75
|
+
DG.Column.fromStrings('ProteinGroups', ['P1', 'P2']),
|
|
76
|
+
]);
|
|
77
|
+
df.name = 'No-abundance test';
|
|
78
|
+
const tv = grok.shell.addTableView(df);
|
|
79
|
+
try {
|
|
80
|
+
expect(dockRankAbundanceCharts(tv, df), false);
|
|
81
|
+
} finally {
|
|
82
|
+
tv.close();
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import * as DG from 'datagrok-api/dg';
|
|
2
|
+
|
|
3
|
+
import {findColumn} from './column-detection';
|
|
4
|
+
import {getGroups} from '../analysis/experiment-setup';
|
|
5
|
+
|
|
6
|
+
/** Per-condition abundance, in log10(MS intensity) space, one value per protein
|
|
7
|
+
* row (null when the protein was not quantified in that condition). */
|
|
8
|
+
export interface ConditionAbundance {
|
|
9
|
+
name: string;
|
|
10
|
+
log10Intensity: (number | null)[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Abundance for the two experiment conditions — the input the rank-abundance
|
|
14
|
+
* (dynamic-range) plot ranks and plots. */
|
|
15
|
+
export interface AbundanceByCondition {
|
|
16
|
+
group1: ConditionAbundance;
|
|
17
|
+
group2: ConditionAbundance;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Spectronaut Candidates carries pre-computed per-group mean abundance in these
|
|
21
|
+
* two columns (kept verbatim by the Candidates parser). group1 = Numerator. */
|
|
22
|
+
const QTY_NUM_HINTS = ['avg group quantity numerator'];
|
|
23
|
+
const QTY_DEN_HINTS = ['avg group quantity denominator'];
|
|
24
|
+
|
|
25
|
+
/** Below this max value a column reads as log2-scale (log2 MS intensities rarely
|
|
26
|
+
* exceed ~40; linear intensities are >>1000). Mirrors the log2-status heuristic
|
|
27
|
+
* in `parsers/shared-utils`. */
|
|
28
|
+
const LOG2_MAX_THRESHOLD = 45;
|
|
29
|
+
|
|
30
|
+
/** log10 of a strictly-positive value, else null. */
|
|
31
|
+
function log10OrNull(v: number): number | null {
|
|
32
|
+
return Number.isFinite(v) && v > 0 ? Math.log10(v) : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Single linear-abundance column → per-row log10. */
|
|
36
|
+
function columnToLog10(col: DG.Column): (number | null)[] {
|
|
37
|
+
const out: (number | null)[] = new Array(col.length);
|
|
38
|
+
for (let i = 0; i < col.length; i++)
|
|
39
|
+
out[i] = col.isNone(i) ? null : log10OrNull(col.get(i) as number);
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** How a group's per-sample intensity columns are collapsed to one per-protein
|
|
44
|
+
* abundance on the Report path:
|
|
45
|
+
* - `arithmetic` — delinearize (2^v) log2 columns, average in linear space, then
|
|
46
|
+
* log10. A true intensity mean; outlier-sensitive. Default (rank-abundance).
|
|
47
|
+
* - `geometric` — average in log space (mean of log10 values). log10 of the
|
|
48
|
+
* geometric mean; the conventional summary for log-normal MS intensities and
|
|
49
|
+
* the one the abundance-correlation viewer uses. */
|
|
50
|
+
export type ReportMean = 'arithmetic' | 'geometric';
|
|
51
|
+
|
|
52
|
+
/** Per-row abundance (log10 space) across a group's intensity columns, collapsed
|
|
53
|
+
* per `mode`. When the columns are log2-scale (Report analysis columns are
|
|
54
|
+
* log2-transformed) each value is treated accordingly so the axis is consistent
|
|
55
|
+
* with the Candidates path regardless of the source scale. */
|
|
56
|
+
function groupMeanToLog10(
|
|
57
|
+
df: DG.DataFrame, colNames: string[], mode: ReportMean,
|
|
58
|
+
): (number | null)[] {
|
|
59
|
+
const cols = colNames.map((n) => df.col(n)).filter((c): c is DG.Column => c != null);
|
|
60
|
+
const n = df.rowCount;
|
|
61
|
+
const out: (number | null)[] = new Array(n).fill(null);
|
|
62
|
+
if (cols.length === 0) return out;
|
|
63
|
+
|
|
64
|
+
// Detect log2 scale from the observed maximum across the group's columns.
|
|
65
|
+
let maxVal = -Infinity;
|
|
66
|
+
for (const c of cols) {
|
|
67
|
+
for (let i = 0; i < n; i++) {
|
|
68
|
+
if (!c.isNone(i)) {
|
|
69
|
+
const v = c.get(i) as number;
|
|
70
|
+
if (Number.isFinite(v) && v > maxVal) maxVal = v;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const isLog2 = Number.isFinite(maxVal) && maxVal < LOG2_MAX_THRESHOLD;
|
|
75
|
+
const LOG10_2 = Math.log10(2);
|
|
76
|
+
|
|
77
|
+
for (let i = 0; i < n; i++) {
|
|
78
|
+
let sum = 0; let count = 0;
|
|
79
|
+
for (const c of cols) {
|
|
80
|
+
if (c.isNone(i)) continue;
|
|
81
|
+
const v = c.get(i) as number;
|
|
82
|
+
if (!Number.isFinite(v)) continue;
|
|
83
|
+
if (mode === 'geometric') {
|
|
84
|
+
// Mean of log10 values = log10(geometric mean). A log2 column is already
|
|
85
|
+
// a log, so rebase (v * log10 2); a linear column is log10'd (skip <=0).
|
|
86
|
+
const lv = isLog2 ? v * LOG10_2 : log10OrNull(v);
|
|
87
|
+
if (lv == null) continue;
|
|
88
|
+
sum += lv;
|
|
89
|
+
} else {
|
|
90
|
+
// Arithmetic mean of linear intensities, log10'd after the loop.
|
|
91
|
+
sum += isLog2 ? Math.pow(2, v) : v;
|
|
92
|
+
}
|
|
93
|
+
count++;
|
|
94
|
+
}
|
|
95
|
+
if (count === 0) { out[i] = null; continue; }
|
|
96
|
+
out[i] = mode === 'geometric' ? sum / count : log10OrNull(sum / count);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resolves per-condition abundance for the rank-abundance / dynamic-range plot,
|
|
103
|
+
* working on both pipeline shapes:
|
|
104
|
+
*
|
|
105
|
+
* - **Candidates** — the pre-computed `AVG Group Quantity Numerator/Denominator`
|
|
106
|
+
* columns (linear); group1 = Numerator, group2 = Denominator. This is the
|
|
107
|
+
* shape CK-omics builds this plot from.
|
|
108
|
+
* - **Report** — the per-group mean of each group's intensity columns (from the
|
|
109
|
+
* `proteomics.groups` assignment), delinearized when log2-scale.
|
|
110
|
+
*
|
|
111
|
+
* Returns null when neither shape yields abundance (e.g. a Report with no group
|
|
112
|
+
* assignment yet, or an older Candidates export lacking group quantities) — the
|
|
113
|
+
* caller warns and no-ops.
|
|
114
|
+
*
|
|
115
|
+
* `opts.reportMean` selects how the Report path collapses each group's per-sample
|
|
116
|
+
* columns (default `arithmetic`, so rank-abundance is unchanged; the
|
|
117
|
+
* abundance-correlation viewer passes `geometric`). It does not affect the
|
|
118
|
+
* Candidates path, which reads the vendor's pre-computed per-group quantity.
|
|
119
|
+
*/
|
|
120
|
+
export function findAbundanceByCondition(
|
|
121
|
+
df: DG.DataFrame, opts?: {reportMean?: ReportMean},
|
|
122
|
+
): AbundanceByCondition | null {
|
|
123
|
+
const groups = getGroups(df);
|
|
124
|
+
const mode: ReportMean = opts?.reportMean ?? 'arithmetic';
|
|
125
|
+
|
|
126
|
+
// Candidates path — precomputed per-group means.
|
|
127
|
+
const numCol = findColumn(df, '', QTY_NUM_HINTS);
|
|
128
|
+
const denCol = findColumn(df, '', QTY_DEN_HINTS);
|
|
129
|
+
if (numCol && denCol) {
|
|
130
|
+
return {
|
|
131
|
+
group1: {name: groups?.group1.name || 'Numerator', log10Intensity: columnToLog10(numCol)},
|
|
132
|
+
group2: {name: groups?.group2.name || 'Denominator', log10Intensity: columnToLog10(denCol)},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Report path — per-group summary of each group's intensity columns.
|
|
137
|
+
if (groups && groups.group1.columns.length > 0 && groups.group2.columns.length > 0) {
|
|
138
|
+
return {
|
|
139
|
+
group1: {name: groups.group1.name || 'Group 1', log10Intensity: groupMeanToLog10(df, groups.group1.columns, mode)},
|
|
140
|
+
group2: {name: groups.group2.name || 'Group 2', log10Intensity: groupMeanToLog10(df, groups.group2.columns, mode)},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import * as grok from 'datagrok-api/grok';
|
|
2
|
+
import * as DG from 'datagrok-api/dg';
|
|
3
|
+
import {debounceTime} from 'rxjs/operators';
|
|
4
|
+
|
|
5
|
+
import {findAbundanceByCondition} from '../utils/abundance-detection';
|
|
6
|
+
import {findColumn} from '../utils/column-detection';
|
|
7
|
+
import {ensureDirectionColumn} from './volcano';
|
|
8
|
+
import {SEMTYPE, DEFAULT_FC_THRESHOLD, DEFAULT_P_THRESHOLD} from '../utils/proteomics-types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Abundance Correlation viewer — one dot per protein, its abundance in group 1
|
|
12
|
+
* (y) against group 2 (x), on geometric-mean log10 axes. Answers: how does each
|
|
13
|
+
* protein's abundance compare between the two conditions, and how tightly do the
|
|
14
|
+
* conditions track overall? Proteins on the y=x diagonal are unchanged; those off
|
|
15
|
+
* it are differentially abundant. Consolidates the former Group-Mean Correlation
|
|
16
|
+
* and Correlation Plot viewers.
|
|
17
|
+
*
|
|
18
|
+
* Works on both pipeline shapes via `findAbundanceByCondition`: a Spectronaut
|
|
19
|
+
* Candidates file (pre-computed AVG Group Quantity) or an annotated Report
|
|
20
|
+
* (per-sample intensities collapsed by geometric mean). Points are colored by the
|
|
21
|
+
* volcano's `direction` (reused when present so the two plots never disagree); the
|
|
22
|
+
* title carries Pearson r and Spearman rho recomputed live over the current
|
|
23
|
+
* filter. No regression line or fold-change guides — the y=x diagonal plus the
|
|
24
|
+
* `direction` color already carry effect size and the significance call.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Hidden log10-abundance axis columns. Deliberately distinct from
|
|
28
|
+
* rank-abundance's `log10 intensity:` prefix — that viewer uses the arithmetic
|
|
29
|
+
* mean, this one the geometric, so they must not share a column and overwrite
|
|
30
|
+
* each other when both are opened on the same table. */
|
|
31
|
+
const LOG10_PREFIX = 'log10 abundance: ';
|
|
32
|
+
const DIAGONAL_COLOR = '#888888';
|
|
33
|
+
|
|
34
|
+
/** Bulk-init a hidden float column, replacing any prior one so re-runs don't
|
|
35
|
+
* duplicate. */
|
|
36
|
+
function ensureFreshHiddenFloat(df: DG.DataFrame, name: string, values: (number | null)[]): string {
|
|
37
|
+
if (df.columns.contains(name)) df.columns.remove(name);
|
|
38
|
+
const c = df.columns.addNewFloat(name);
|
|
39
|
+
c.init((i) => values[i]);
|
|
40
|
+
c.setTag('.hidden', 'true');
|
|
41
|
+
return name;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Pearson r over the pairwise-complete rows and the pair count. */
|
|
45
|
+
export function pearson(x: (number | null)[], y: (number | null)[]): {r: number; n: number} {
|
|
46
|
+
let n = 0; let sx = 0; let sy = 0; let sxx = 0; let syy = 0; let sxy = 0;
|
|
47
|
+
for (let i = 0; i < x.length; i++) {
|
|
48
|
+
const a = x[i]; const b = y[i];
|
|
49
|
+
if (a == null || b == null || !Number.isFinite(a) || !Number.isFinite(b)) continue;
|
|
50
|
+
n++; sx += a; sy += b; sxx += a * a; syy += b * b; sxy += a * b;
|
|
51
|
+
}
|
|
52
|
+
if (n < 2) return {r: NaN, n};
|
|
53
|
+
const cov = sxy - (sx * sy) / n;
|
|
54
|
+
const vx = sxx - (sx * sx) / n;
|
|
55
|
+
const vy = syy - (sy * sy) / n;
|
|
56
|
+
const denom = Math.sqrt(vx * vy);
|
|
57
|
+
return {r: denom > 0 ? cov / denom : NaN, n};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Fractional ranks (1-based), ties averaged. Nulls keep their slot as null so
|
|
61
|
+
* the caller can drop the pair. */
|
|
62
|
+
function fractionalRanks(v: (number | null)[]): (number | null)[] {
|
|
63
|
+
const idx = v.map((_, i) => i).filter((i) => v[i] != null && Number.isFinite(v[i] as number));
|
|
64
|
+
idx.sort((a, b) => (v[a] as number) - (v[b] as number));
|
|
65
|
+
const out: (number | null)[] = new Array(v.length).fill(null);
|
|
66
|
+
let i = 0;
|
|
67
|
+
while (i < idx.length) {
|
|
68
|
+
let j = i;
|
|
69
|
+
while (j + 1 < idx.length && (v[idx[j + 1]] as number) === (v[idx[i]] as number)) j++;
|
|
70
|
+
const avg = (i + j) / 2 + 1;
|
|
71
|
+
for (let k = i; k <= j; k++) out[idx[k]] = avg;
|
|
72
|
+
i = j + 1;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Spearman rho = Pearson over fractional ranks of the pairwise-complete rows. */
|
|
78
|
+
export function spearman(x: (number | null)[], y: (number | null)[]): number {
|
|
79
|
+
return pearson(fractionalRanks(x), fractionalRanks(y)).r;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Reads two columns over the current filter into aligned (null-preserving) arrays. */
|
|
83
|
+
function filteredPairs(df: DG.DataFrame, xName: string, yName: string): {xs: (number | null)[]; ys: (number | null)[]} {
|
|
84
|
+
const xc = df.col(xName); const yc = df.col(yName);
|
|
85
|
+
const xs: (number | null)[] = []; const ys: (number | null)[] = [];
|
|
86
|
+
if (!xc || !yc) return {xs, ys};
|
|
87
|
+
for (let i = 0; i < df.rowCount; i++) {
|
|
88
|
+
if (!df.filter.get(i)) continue;
|
|
89
|
+
xs.push(xc.isNone(i) ? null : (xc.get(i) as number));
|
|
90
|
+
ys.push(yc.isNone(i) ? null : (yc.get(i) as number));
|
|
91
|
+
}
|
|
92
|
+
return {xs, ys};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The on-canvas description (group comparison + correlation stats) over the
|
|
96
|
+
* current filter. Rendered via the scatterplot's `description` overlay — the
|
|
97
|
+
* `title` property is not painted in a docked viewer (the dock tab supplies the
|
|
98
|
+
* header), so the stats live here where they are actually visible. */
|
|
99
|
+
export function correlationDescription(
|
|
100
|
+
g1Name: string, g2Name: string, df: DG.DataFrame, xName: string, yName: string,
|
|
101
|
+
): string {
|
|
102
|
+
const {xs, ys} = filteredPairs(df, xName, yName);
|
|
103
|
+
const {r, n} = pearson(xs, ys);
|
|
104
|
+
const rho = spearman(xs, ys);
|
|
105
|
+
const rStr = Number.isFinite(r) ? r.toFixed(2) : 'NA';
|
|
106
|
+
const rhoStr = Number.isFinite(rho) ? rho.toFixed(2) : 'NA';
|
|
107
|
+
return `${g1Name} vs ${g2Name} — Pearson r = ${rStr} · Spearman ρ = ${rhoStr} · n = ${n}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Builds the abundance correlation scatter, or returns null when the frame
|
|
112
|
+
* carries no resolvable per-condition abundance (caller warns). Group naming
|
|
113
|
+
* follows the package (group1 = numerator = y).
|
|
114
|
+
*/
|
|
115
|
+
export function createAbundanceCorrelation(df: DG.DataFrame): DG.ScatterPlotViewer | null {
|
|
116
|
+
const ab = findAbundanceByCondition(df, {reportMean: 'geometric'});
|
|
117
|
+
if (!ab) return null;
|
|
118
|
+
|
|
119
|
+
const yName = ensureFreshHiddenFloat(df, LOG10_PREFIX + ab.group1.name, ab.group1.log10Intensity);
|
|
120
|
+
const xName = ensureFreshHiddenFloat(df, LOG10_PREFIX + ab.group2.name, ab.group2.log10Intensity);
|
|
121
|
+
|
|
122
|
+
// Reuse the volcano's existing coloring when present so the two plots agree;
|
|
123
|
+
// otherwise classify with the same thresholds. Best-effort — a frame without
|
|
124
|
+
// log2FC (e.g. abundance-only) simply renders uncolored.
|
|
125
|
+
let colorCol: string | undefined;
|
|
126
|
+
try {
|
|
127
|
+
colorCol = df.col('direction') ? 'direction'
|
|
128
|
+
: ensureDirectionColumn(df, DEFAULT_FC_THRESHOLD, DEFAULT_P_THRESHOLD);
|
|
129
|
+
} catch { colorCol = undefined; }
|
|
130
|
+
|
|
131
|
+
// y = x reference diagonal — replace any prior one so re-runs don't stack.
|
|
132
|
+
const diagFormula = `\${${yName}} = \${${xName}}`;
|
|
133
|
+
const fl: any = (df as any).meta?.formulaLines;
|
|
134
|
+
if (fl) {
|
|
135
|
+
try {
|
|
136
|
+
const items: any[] = Array.isArray(fl.items) ? fl.items : [];
|
|
137
|
+
fl.items = items.filter((l) => !(typeof l?.formula === 'string' &&
|
|
138
|
+
l.formula.includes(`\${${xName}}`) && l.formula.includes(`\${${yName}}`)));
|
|
139
|
+
fl.addLine({formula: diagFormula, color: DIAGONAL_COLOR, width: 1, visible: true});
|
|
140
|
+
// addLine defaults an empty title back to the formula, so blank it afterward
|
|
141
|
+
// to suppress the raw `${y} = ${x}` label Datagrok otherwise prints along the line.
|
|
142
|
+
const cur: any[] = Array.isArray(fl.items) ? fl.items : [];
|
|
143
|
+
const added = cur.find((l) => l?.formula === diagFormula);
|
|
144
|
+
if (added) { added.title = ''; fl.items = cur; }
|
|
145
|
+
} catch { /* best effort */ }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const sp = DG.Viewer.scatterPlot(df, {
|
|
149
|
+
x: xName,
|
|
150
|
+
y: yName,
|
|
151
|
+
...(colorCol ? {color: colorCol} : {}),
|
|
152
|
+
showRegressionLine: false,
|
|
153
|
+
showViewerFormulaLines: true,
|
|
154
|
+
showFilteredOutPoints: true,
|
|
155
|
+
markerType: 'circle',
|
|
156
|
+
markerDefaultSize: 5,
|
|
157
|
+
title: 'Abundance Correlation',
|
|
158
|
+
// Stats go in the always-on description overlay (the title isn't painted).
|
|
159
|
+
description: correlationDescription(ab.group1.name, ab.group2.name, df, xName, yName),
|
|
160
|
+
descriptionVisibilityMode: 'Always',
|
|
161
|
+
} as any);
|
|
162
|
+
|
|
163
|
+
// Label with Display Name, gene fallback (matches Group-Mean / Plan 02).
|
|
164
|
+
const labelCol = findColumn(df, SEMTYPE.DISPLAY_NAME, ['display name']) ??
|
|
165
|
+
findColumn(df, SEMTYPE.GENE_SYMBOL, ['gene name', 'gene symbol']);
|
|
166
|
+
if (labelCol) {
|
|
167
|
+
sp.props.labelColumnNames = [labelCol.name];
|
|
168
|
+
sp.props.showLabelsFor = 'MouseOverRow';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Filter-live stats: recompute r/rho on filter changes; tear the subscription
|
|
172
|
+
// down when this viewer closes so it doesn't leak or write to a dead viewer.
|
|
173
|
+
const sub = df.onFilterChanged.pipe(debounceTime(100)).subscribe(() => {
|
|
174
|
+
try {
|
|
175
|
+
sp.setOptions({description: correlationDescription(ab.group1.name, ab.group2.name, df, xName, yName)});
|
|
176
|
+
} catch { /* viewer closed */ }
|
|
177
|
+
});
|
|
178
|
+
const closeSub = grok.events.onViewerClosed.subscribe((args) => {
|
|
179
|
+
const v: any = (args as any)?.args?.viewer;
|
|
180
|
+
if (v && (v as any).dart === (sp as any).dart) {
|
|
181
|
+
sub.unsubscribe();
|
|
182
|
+
closeSub.unsubscribe();
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return sp;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Menu entry point: docks the abundance correlation plot on the active protein
|
|
191
|
+
* view, or warns when there is no per-condition abundance.
|
|
192
|
+
*/
|
|
193
|
+
export function openAbundanceCorrelation(df: DG.DataFrame): void {
|
|
194
|
+
const view = grok.shell.tv;
|
|
195
|
+
if (!view || (view.dataFrame as any)?.dart !== (df as any).dart) {
|
|
196
|
+
grok.shell.warning('Open the protein table first, then run Abundance Correlation.');
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const sp = createAbundanceCorrelation(df);
|
|
200
|
+
if (!sp) {
|
|
201
|
+
grok.shell.warning(
|
|
202
|
+
'No per-condition abundance found. Abundance Correlation needs the Spectronaut ' +
|
|
203
|
+
'Candidates "AVG Group Quantity" columns, or an annotated Report (Annotate ' +
|
|
204
|
+
'Experiment) with per-sample intensities.');
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
view.dockManager.dock(sp, DG.DOCK_TYPE.RIGHT, null, 'Abundance Correlation', 0.5);
|
|
208
|
+
}
|
|
@@ -116,9 +116,25 @@ export function createEnrichmentBarChart(enrichDf: DG.DataFrame, direction?: 'Up
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
/**
|
|
119
|
-
* Wires enrichment
|
|
120
|
-
*
|
|
121
|
-
*
|
|
119
|
+
* Wires enrichment term interactions to the protein DataFrame, keyed on each
|
|
120
|
+
* term's comma-separated `Intersection` member genes → protein rows. All three
|
|
121
|
+
* enrichment charts (grid, dot plot, bar chart) share `enrichDf`, so this single
|
|
122
|
+
* full-frame wiring covers interactions in any of them.
|
|
123
|
+
*
|
|
124
|
+
* Everything drives the volcano's `selection` — the only channel the scatterplot
|
|
125
|
+
* paints visibly for a *set* of proteins (verified live: programmatic
|
|
126
|
+
* `rows.highlight` / `mouseOverRowFunc` do not render). Two channels feed that
|
|
127
|
+
* one selection, with the multi-term selection winning:
|
|
128
|
+
* - (1)+(3) enrichment **selection** (dot-plot rubber-band, shift/ctrl-click
|
|
129
|
+
* bars, ctrl-click grid rows) → the UNION of member proteins across every
|
|
130
|
+
* selected term. An empty enrichment selection clears the volcano selection.
|
|
131
|
+
* - (2) enrichment **current row** (a single clicked term) → that term's member
|
|
132
|
+
* proteins, but ONLY when no terms are selected. While a multi-term selection
|
|
133
|
+
* is active it governs, and current-row changes are ignored — so a trailing
|
|
134
|
+
* current-row event (fired alongside a select gesture) can't clobber the union.
|
|
135
|
+
*
|
|
136
|
+
* The enrichment **filter** is intentionally NOT reflected (4) — filtering terms
|
|
137
|
+
* declutters the enrichment view; it must not repaint the volcano.
|
|
122
138
|
*/
|
|
123
139
|
export function wireEnrichmentToVolcano(
|
|
124
140
|
enrichDf: DG.DataFrame,
|
|
@@ -127,7 +143,7 @@ export function wireEnrichmentToVolcano(
|
|
|
127
143
|
const geneCol = findColumn(proteinDf, SEMTYPE.GENE_SYMBOL, ['gene name', 'gene symbol']);
|
|
128
144
|
if (!geneCol) return rxjs.Subscription.EMPTY;
|
|
129
145
|
|
|
130
|
-
// Build gene-
|
|
146
|
+
// Build gene -> protein-row index once.
|
|
131
147
|
const geneToRows = new Map<string, number[]>();
|
|
132
148
|
for (let i = 0; i < proteinDf.rowCount; i++) {
|
|
133
149
|
if (!geneCol.isNone(i)) {
|
|
@@ -137,31 +153,49 @@ export function wireEnrichmentToVolcano(
|
|
|
137
153
|
}
|
|
138
154
|
}
|
|
139
155
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
156
|
+
// Union of protein rows whose gene is a member of ANY of the given enrichment
|
|
157
|
+
// term rows (via each term's comma-separated Intersection string).
|
|
158
|
+
const proteinRowsForTerms = (termRows: Int32Array | number[]): Set<number> => {
|
|
159
|
+
const out = new Set<number>();
|
|
144
160
|
const intersectionCol = enrichDf.col('Intersection');
|
|
145
|
-
if (!intersectionCol) return;
|
|
161
|
+
if (!intersectionCol) return out;
|
|
162
|
+
for (const r of termRows) {
|
|
163
|
+
if (r < 0 || intersectionCol.isNone(r)) continue;
|
|
164
|
+
const memberGenesStr = intersectionCol.get(r) as string;
|
|
165
|
+
if (!memberGenesStr) continue;
|
|
166
|
+
for (const gene of memberGenesStr.split(',').map((g) => g.trim()).filter(Boolean)) {
|
|
167
|
+
const rows = geneToRows.get(gene);
|
|
168
|
+
if (rows) for (const row of rows) out.add(row);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
};
|
|
146
173
|
|
|
147
|
-
|
|
148
|
-
|
|
174
|
+
// Replace the volcano selection with exactly `rows` (empty set → cleared).
|
|
175
|
+
const selectProteins = (rows: Set<number>): void => {
|
|
176
|
+
proteinDf.selection.setAll(false, false);
|
|
177
|
+
for (const row of rows) proteinDf.selection.set(row, true, false);
|
|
178
|
+
proteinDf.selection.fireChanged();
|
|
179
|
+
};
|
|
149
180
|
|
|
150
|
-
|
|
181
|
+
// (1)+(3) Selected terms → union of member proteins; no selection → cleared.
|
|
182
|
+
const applySelection = (): void => {
|
|
183
|
+
const termRows = enrichDf.selection.getSelectedIndexes();
|
|
184
|
+
selectProteins(termRows.length > 0 ? proteinRowsForTerms(termRows) : new Set());
|
|
185
|
+
};
|
|
151
186
|
|
|
152
|
-
|
|
153
|
-
|
|
187
|
+
// (2) Single current term → that term's member proteins, but only while no
|
|
188
|
+
// terms are selected; an active multi-term selection governs and wins.
|
|
189
|
+
const applyCurrentRow = (): void => {
|
|
190
|
+
if (enrichDf.selection.trueCount > 0) return;
|
|
191
|
+
const idx = enrichDf.currentRowIdx;
|
|
192
|
+
selectProteins(idx >= 0 ? proteinRowsForTerms([idx]) : new Set());
|
|
193
|
+
};
|
|
154
194
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
for (const row of rows)
|
|
160
|
-
proteinDf.selection.set(row, true, false);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
proteinDf.selection.fireChanged();
|
|
164
|
-
});
|
|
195
|
+
const sub = new rxjs.Subscription();
|
|
196
|
+
sub.add(enrichDf.onSelectionChanged.subscribe(() => applySelection()));
|
|
197
|
+
sub.add(enrichDf.onCurrentRowChanged.subscribe(() => applyCurrentRow()));
|
|
198
|
+
return sub;
|
|
165
199
|
}
|
|
166
200
|
|
|
167
201
|
/** True when `enrichDf` carries a Direction column with at least one row in
|