@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +18 -5
  3. package/dist/package-test.js +1 -1
  4. package/dist/package-test.js.map +1 -1
  5. package/dist/package.js +1 -1
  6. package/dist/package.js.map +1 -1
  7. package/docs/personas-and-capabilities.md +16 -0
  8. package/docs/publishing-design-rationale.md +140 -0
  9. package/package.json +17 -3
  10. package/package.png +0 -0
  11. package/src/analysis/imputation.ts +3 -1
  12. package/src/analysis/normalization.ts +9 -4
  13. package/src/analysis/spc-storage.ts +1 -1
  14. package/src/menu.ts +5 -2
  15. package/src/package-api.ts +10 -2
  16. package/src/package-test.ts +3 -1
  17. package/src/package.g.ts +17 -3
  18. package/src/package.ts +31 -15
  19. package/src/panels/published-analysis-panel.ts +2 -2
  20. package/src/publishing/assert-published-shape.ts +2 -2
  21. package/src/publishing/package-settings-editor.ts +125 -0
  22. package/src/publishing/publish-project.ts +8 -8
  23. package/src/publishing/publish-settings.ts +57 -3
  24. package/src/publishing/publish-state.ts +45 -16
  25. package/src/publishing/reviewer-groups.ts +24 -0
  26. package/src/publishing/share-dialog.ts +42 -27
  27. package/src/publishing/trim-dataframe.ts +8 -1
  28. package/src/tests/abundance-correlation.ts +135 -0
  29. package/src/tests/analysis.ts +63 -0
  30. package/src/tests/enrichment-visualization.ts +95 -9
  31. package/src/tests/enrichment.ts +2 -1
  32. package/src/tests/project-vocabulary.ts +39 -0
  33. package/src/tests/publish-roundtrip.ts +40 -40
  34. package/src/tests/publish-spike.ts +2 -2
  35. package/src/tests/rank-abundance.ts +85 -0
  36. package/src/utils/abundance-detection.ts +145 -0
  37. package/src/viewers/abundance-correlation.ts +208 -0
  38. package/src/viewers/enrichment-viewers.ts +58 -24
  39. package/src/viewers/rank-abundance.ts +153 -0
  40. package/src/viewers/volcano.ts +3 -0
  41. package/test-console-output-1.log +1103 -1066
  42. package/test-record-1.mp4 +0 -0
  43. package/src/tests/group-mean-correlation.ts +0 -139
  44. 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.published_target',
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.published_target': 'SPIKE-TARGET-A',
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 row changes to protein DataFrame selection.
120
- * When a row is selected in the enrichment table, matching protein rows
121
- * (by gene symbol from the Intersection column) are highlighted.
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-to-row index
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
- return enrichDf.onCurrentRowChanged.subscribe(() => {
141
- const rowIdx = enrichDf.currentRowIdx;
142
- if (rowIdx < 0) return;
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
- const memberGenesStr = intersectionCol.get(rowIdx) as string;
148
- if (!memberGenesStr) return;
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
- const memberGenes = memberGenesStr.split(',').map((g) => g.trim()).filter(Boolean);
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
- // Clear previous selection
153
- proteinDf.selection.setAll(false, false);
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
- // Set selection for matching genes
156
- for (const gene of memberGenes) {
157
- const rows = geneToRows.get(gene);
158
- if (rows) {
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