@datagrok/proteomics 1.0.1 → 1.2.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 (116) hide show
  1. package/CHANGELOG.md +52 -3
  2. package/CLAUDE.md +178 -0
  3. package/README.md +195 -3
  4. package/detectors.js +152 -9
  5. package/dist/package-test.js +1 -1744
  6. package/dist/package-test.js.map +1 -1
  7. package/dist/package.js +1 -146
  8. package/dist/package.js.map +1 -1
  9. package/docs/personas-and-capabilities.md +165 -0
  10. package/files/demo/README.md +264 -0
  11. package/files/demo/cptac-spike-in.txt +1571 -0
  12. package/files/demo/enrichment-demo.csv +120 -0
  13. package/files/demo/fragpipe-smoke-test.tsv +11 -0
  14. package/files/demo/proteinGroups.txt +28 -0
  15. package/files/demo/spectronaut-hye-candidates.tsv +94 -0
  16. package/files/demo/spectronaut-hye-demo.tsv +8761 -0
  17. package/files/demo/spectronaut-hye-mix.tsv +8761 -0
  18. package/files/demo/spectronaut-hye-precursor-golden.json +938 -0
  19. package/files/demo/spectronaut-hye-precursor-golden.tsv +235 -0
  20. package/files/demo/spectronaut-hye-precursor.tsv +493 -0
  21. package/images/enrichment-crosslink.png +0 -0
  22. package/images/enrichment-term-selected.png +0 -0
  23. package/images/hero.png +0 -0
  24. package/images/pipeline.svg +80 -0
  25. package/package.json +87 -63
  26. package/scripts/deqms_de.R +60 -0
  27. package/scripts/limma_de.R +42 -0
  28. package/scripts/vsn_normalize.R +19 -0
  29. package/src/analysis/differential-expression.ts +450 -0
  30. package/src/analysis/enrichment-export.ts +101 -0
  31. package/src/analysis/enrichment.ts +602 -0
  32. package/src/analysis/experiment-setup.ts +199 -0
  33. package/src/analysis/imputation.ts +407 -0
  34. package/src/analysis/log2-scale.ts +139 -0
  35. package/src/analysis/normalization.ts +255 -0
  36. package/src/analysis/pca.ts +254 -0
  37. package/src/analysis/spc-storage.ts +515 -0
  38. package/src/analysis/spc.ts +544 -0
  39. package/src/analysis/subcellular-location.ts +431 -0
  40. package/src/demo/enrichment-demo.ts +94 -0
  41. package/src/demo/proteomics-demo.ts +123 -0
  42. package/src/global.d.ts +15 -0
  43. package/src/menu.ts +133 -0
  44. package/src/package-api.ts +136 -14
  45. package/src/package-test.ts +45 -20
  46. package/src/package.g.ts +161 -0
  47. package/src/package.ts +1029 -17
  48. package/src/panels/protein-focus.ts +63 -0
  49. package/src/panels/published-analysis-panel.ts +151 -0
  50. package/src/panels/uniprot-panel.ts +349 -0
  51. package/src/parsers/fragpipe-parser.ts +200 -0
  52. package/src/parsers/generic-parser.ts +197 -0
  53. package/src/parsers/maxquant-parser.ts +162 -0
  54. package/src/parsers/shared-utils.ts +163 -0
  55. package/src/parsers/spectronaut-candidates-parser.ts +307 -0
  56. package/src/parsers/spectronaut-parser.ts +604 -0
  57. package/src/publishing/assert-published-shape.ts +260 -0
  58. package/src/publishing/post-open-recovery.ts +104 -0
  59. package/src/publishing/publish-project.ts +515 -0
  60. package/src/publishing/publish-settings.ts +59 -0
  61. package/src/publishing/publish-state.ts +316 -0
  62. package/src/publishing/share-dialog.ts +171 -0
  63. package/src/publishing/trim-dataframe.ts +247 -0
  64. package/src/tests/analysis.ts +658 -0
  65. package/src/tests/enrichment-export.ts +61 -0
  66. package/src/tests/enrichment-visualization.ts +340 -0
  67. package/src/tests/enrichment.ts +224 -0
  68. package/src/tests/fragpipe-e2e.ts +74 -0
  69. package/src/tests/fragpipe-parser.ts +147 -0
  70. package/src/tests/gene-label-resolver.ts +387 -0
  71. package/src/tests/generic-parser.ts +152 -0
  72. package/src/tests/group-mean-correlation.ts +139 -0
  73. package/src/tests/log2-scale.ts +93 -0
  74. package/src/tests/organisms.ts +56 -0
  75. package/src/tests/parsers.ts +182 -0
  76. package/src/tests/publish-roundtrip.ts +584 -0
  77. package/src/tests/publish-spike.ts +377 -0
  78. package/src/tests/qc-dashboard.ts +210 -0
  79. package/src/tests/smart-pathway-filter.ts +193 -0
  80. package/src/tests/spc-formula-lines-spike.ts +129 -0
  81. package/src/tests/spc.ts +640 -0
  82. package/src/tests/spectronaut-candidates-e2e.ts +140 -0
  83. package/src/tests/spectronaut-candidates-parser.ts +398 -0
  84. package/src/tests/spectronaut-parser.ts +668 -0
  85. package/src/tests/subcellular-location.ts +361 -0
  86. package/src/tests/uniprot-panel.ts +202 -0
  87. package/src/tests/volcano.ts +603 -0
  88. package/src/utils/column-detection.ts +28 -0
  89. package/src/utils/gene-label-resolver.ts +443 -0
  90. package/src/utils/organisms.ts +82 -0
  91. package/src/utils/proteomics-types.ts +30 -0
  92. package/src/viewers/enrichment-viewers.ts +274 -0
  93. package/src/viewers/group-mean-correlation.ts +218 -0
  94. package/src/viewers/heatmap.ts +168 -0
  95. package/src/viewers/pca-plot.ts +169 -0
  96. package/src/viewers/qc-computations.ts +266 -0
  97. package/src/viewers/qc-dashboard.ts +176 -0
  98. package/src/viewers/spc-dashboard.ts +755 -0
  99. package/src/viewers/volcano.ts +690 -0
  100. package/test-console-output-1.log +2055 -0
  101. package/test-record-1.mp4 +0 -0
  102. package/tools/derive-precursor-golden-sidecar.mjs +81 -0
  103. package/tools/generate-enrichment-fixture.sh +160 -0
  104. package/tools/generate-spectronaut-candidates-fixture.mjs +212 -0
  105. package/tools/generate-spectronaut-precursor-fixture.mjs +128 -0
  106. package/tools/spectronaut-aggregate.sh +46 -0
  107. package/tools/spectronaut-aggregate.sql +80 -0
  108. package/tsconfig.json +18 -71
  109. package/webpack.config.js +86 -45
  110. package/.eslintignore +0 -1
  111. package/.eslintrc.json +0 -56
  112. package/LICENSE +0 -674
  113. package/package.png +0 -0
  114. package/scripts/number_antibody.py +0 -190
  115. package/scripts/number_antibody_abnumber.py +0 -177
  116. package/scripts/number_antibody_anarci.py +0 -200
@@ -0,0 +1,274 @@
1
+ import * as grok from 'datagrok-api/grok';
2
+ import * as ui from 'datagrok-api/ui';
3
+ import * as DG from 'datagrok-api/dg';
4
+ import * as rxjs from 'rxjs';
5
+ import {findColumn} from '../utils/column-detection';
6
+ import {SEMTYPE} from '../utils/proteomics-types';
7
+
8
+ /** Module-level subscriptions for cleanup on re-open. */
9
+ let activeSubscriptions: rxjs.Subscription[] = [];
10
+
11
+ /** Default number of terms each dot/bar chart shows (per direction). */
12
+ export const CHART_TOP_N = 15;
13
+
14
+ /** Value/color column the dot + bar charts read. Kept as a plain (non-`~`)
15
+ * name because it is a viewer VALUE binding — a missing value column hard-fails
16
+ * a bar chart on reopen (the original "Column negLog10FDR does not exist" bug). */
17
+ export const NEG_LOG10_FDR_COL = 'negLog10FDR';
18
+ /** Hidden marker column (auto-hidden via `~` prefix, ignored by the publish
19
+ * shape contract) flagging the top-N terms per direction. Used only inside the
20
+ * per-viewer formula filter, where a parse failure degrades to "show all"
21
+ * rather than crashing — so the `~` name is safe here but not for the value. */
22
+ export const CHART_TOP_MARKER_COL = '~enrichChartTop';
23
+
24
+ /**
25
+ * Adds the two derived columns the enrichment dot/bar charts bind to, directly
26
+ * onto `enrichDf` (idempotent — ensureFreshFloat pattern):
27
+ * - `negLog10FDR` — -log10(FDR); dot color + bar value. FDR=0 (underflow /
28
+ * extreme enrichment) maps to the float-underflow ceiling so it stays
29
+ * visible without colliding with real values like FDR=1e-10.
30
+ * - `~enrichChartTop` — true for the top-N terms by FDR (ascending) within
31
+ * each Direction, or overall when there is no Direction column.
32
+ *
33
+ * Both charts bind to `enrichDf` itself rather than to cloned subsets: docked
34
+ * viewers rebind to their host view's primary frame on project reopen, so the
35
+ * columns they read must live on that frame (else the bar chart fatal-errors).
36
+ * Keeping everything on one frame also stops the subset tables from leaking
37
+ * into a published project's table tree.
38
+ */
39
+ export function prepareEnrichmentChartColumns(enrichDf: DG.DataFrame, topN: number = CHART_TOP_N): void {
40
+ const fdrCol = enrichDf.col('FDR');
41
+ if (!fdrCol) return;
42
+
43
+ const UNDERFLOW_NEGLOG10 = -Math.log10(Number.MIN_VALUE);
44
+ const fdrRaw = fdrCol.getRawData() as Float32Array | Float64Array;
45
+ if (enrichDf.columns.contains(NEG_LOG10_FDR_COL)) enrichDf.columns.remove(NEG_LOG10_FDR_COL);
46
+ const negLogCol = enrichDf.columns.addNewFloat(NEG_LOG10_FDR_COL);
47
+ negLogCol.init((i) => fdrRaw[i] > 0 ? -Math.log10(fdrRaw[i]) : UNDERFLOW_NEGLOG10);
48
+ // Keep it out of the reviewer's grid (best-effort; `~` isn't usable on a
49
+ // viewer value binding). If the tag is stripped on round-trip the only cost
50
+ // is one extra, meaningful numeric column in the grid.
51
+ negLogCol.setTag('.hidden', 'true');
52
+
53
+ // Rank terms by FDR (ascending) within each direction and mark the top N.
54
+ const dirCol = enrichDf.col('Direction');
55
+ const groups = new Map<string, {idx: number; fdr: number}[]>();
56
+ for (let i = 0; i < enrichDf.rowCount; i++) {
57
+ if (fdrCol.isNone(i)) continue;
58
+ const key = dirCol ? String(dirCol.get(i)) : '';
59
+ if (!groups.has(key)) groups.set(key, []);
60
+ groups.get(key)!.push({idx: i, fdr: fdrCol.get(i) as number});
61
+ }
62
+ const topMask = new Array<boolean>(enrichDf.rowCount).fill(false);
63
+ for (const arr of groups.values()) {
64
+ arr.sort((a, b) => a.fdr - b.fdr);
65
+ for (let k = 0; k < Math.min(topN, arr.length); k++)
66
+ topMask[arr[k].idx] = true;
67
+ }
68
+ if (enrichDf.columns.contains(CHART_TOP_MARKER_COL)) enrichDf.columns.remove(CHART_TOP_MARKER_COL);
69
+ enrichDf.columns.addNewBool(CHART_TOP_MARKER_COL).init((i) => topMask[i]);
70
+ }
71
+
72
+ /** Per-viewer formula filter: the top-N marked terms, optionally narrowed to one
73
+ * regulation direction. Degrades to "show all rows" if the platform drops the
74
+ * formula on reopen — never a missing-column crash. */
75
+ function chartFilter(direction?: 'Up' | 'Down'): string {
76
+ const clauses = [`\${${CHART_TOP_MARKER_COL}}`];
77
+ if (direction) clauses.push(`\${Direction} == "${direction}"`);
78
+ return clauses.join(' and ');
79
+ }
80
+
81
+ /**
82
+ * Creates an enrichment dot plot bound to the shared enrichment frame.
83
+ * X = Gene Ratio, Y = Term Name, size = Gene Count, color = -log10(FDR).
84
+ * Pass `direction` to split into Up/Down via a per-viewer formula filter.
85
+ */
86
+ export function createEnrichmentDotPlot(enrichDf: DG.DataFrame, direction?: 'Up' | 'Down'): DG.ScatterPlotViewer {
87
+ const sp = DG.Viewer.scatterPlot(enrichDf, {
88
+ x: 'Gene Ratio',
89
+ y: 'Term Name',
90
+ sizeColumnName: 'Gene Count',
91
+ colorColumnName: NEG_LOG10_FDR_COL,
92
+ markerMinSize: 5,
93
+ markerMaxSize: 25,
94
+ filter: chartFilter(direction),
95
+ title: direction ? `Enrichment — ${direction}` : 'Enrichment Dot Plot',
96
+ } as any);
97
+ return sp;
98
+ }
99
+
100
+ /**
101
+ * Creates an enrichment bar chart (top terms ranked by -log10(FDR)) bound to the
102
+ * shared enrichment frame. Pass `direction` to split into Up/Down.
103
+ */
104
+ export function createEnrichmentBarChart(enrichDf: DG.DataFrame, direction?: 'Up' | 'Down'): DG.Viewer {
105
+ const bar = DG.Viewer.barChart(enrichDf, {
106
+ splitColumnName: 'Term Name',
107
+ valueColumnName: NEG_LOG10_FDR_COL,
108
+ valueAggrType: 'avg',
109
+ barSortType: 'by value',
110
+ barSortOrder: 'desc',
111
+ orientation: 'Horizontal',
112
+ filter: chartFilter(direction),
113
+ title: direction ? `Top Enriched — ${direction}` : 'Top Enriched Terms',
114
+ } as any);
115
+ return bar;
116
+ }
117
+
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.
122
+ */
123
+ export function wireEnrichmentToVolcano(
124
+ enrichDf: DG.DataFrame,
125
+ proteinDf: DG.DataFrame,
126
+ ): rxjs.Subscription {
127
+ const geneCol = findColumn(proteinDf, SEMTYPE.GENE_SYMBOL, ['gene name', 'gene symbol']);
128
+ if (!geneCol) return rxjs.Subscription.EMPTY;
129
+
130
+ // Build gene-to-row index
131
+ const geneToRows = new Map<string, number[]>();
132
+ for (let i = 0; i < proteinDf.rowCount; i++) {
133
+ if (!geneCol.isNone(i)) {
134
+ const gene = (geneCol.get(i) as string).trim();
135
+ if (!geneToRows.has(gene)) geneToRows.set(gene, []);
136
+ geneToRows.get(gene)!.push(i);
137
+ }
138
+ }
139
+
140
+ return enrichDf.onCurrentRowChanged.subscribe(() => {
141
+ const rowIdx = enrichDf.currentRowIdx;
142
+ if (rowIdx < 0) return;
143
+
144
+ const intersectionCol = enrichDf.col('Intersection');
145
+ if (!intersectionCol) return;
146
+
147
+ const memberGenesStr = intersectionCol.get(rowIdx) as string;
148
+ if (!memberGenesStr) return;
149
+
150
+ const memberGenes = memberGenesStr.split(',').map((g) => g.trim()).filter(Boolean);
151
+
152
+ // Clear previous selection
153
+ proteinDf.selection.setAll(false, false);
154
+
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
+ });
165
+ }
166
+
167
+ /** True when `enrichDf` carries a Direction column with at least one row in
168
+ * `direction`. Drives the Up/Down 2×2 vs single-chart layout choice. */
169
+ function hasDirection(enrichDf: DG.DataFrame, direction: 'Up' | 'Down'): boolean {
170
+ const dirCol = enrichDf.col('Direction');
171
+ if (!dirCol) return false;
172
+ for (let i = 0; i < enrichDf.rowCount; i++) {
173
+ if (dirCol.get(i) === direction) return true;
174
+ }
175
+ return false;
176
+ }
177
+
178
+ /**
179
+ * Opens a full enrichment visualization dashboard with dot plot, bar chart,
180
+ * and cross-DataFrame selection wiring to the protein table. When the result
181
+ * carries an R2 Direction column with both Up and Down rows, the Up and Down
182
+ * dot+bar charts are docked side-by-side; otherwise the original single
183
+ * dot+bar layout is used. The Phase-9 volcano cross-link is unchanged.
184
+ */
185
+ export function openEnrichmentVisualization(
186
+ enrichDf: DG.DataFrame,
187
+ proteinDf: DG.DataFrame,
188
+ ): DG.TableView {
189
+ // Clean up previous subscriptions
190
+ for (const sub of activeSubscriptions)
191
+ sub.unsubscribe();
192
+ activeSubscriptions = [];
193
+
194
+ // Find or reuse the enrichment results table view. Match by stable .dart
195
+ // identity, NOT `=== enrichDf`: grok.shell.tableViews returns a fresh toJs
196
+ // wrapper per access, so strict-equality misses an already-open view and
197
+ // spawns a duplicate "Enrichment Results (2)" tab (precedent: the protein-TV
198
+ // lookup that used to live here; 13-07 SUMMARY).
199
+ let existing: DG.TableView | undefined;
200
+ for (const v of grok.shell.tableViews) {
201
+ if ((v.dataFrame as any)?.dart === (enrichDf as any).dart) { existing = v; break; }
202
+ }
203
+ const tv = existing ?? grok.shell.addTableView(enrichDf);
204
+
205
+ // Dock the dot/bar charts onto the ENRICHMENT results view — not the protein
206
+ // view where the volcano lives — so the volcano tab stays uncluttered
207
+ // (light-touch declutter). Surface this view FIRST: docking onto a
208
+ // backgrounded host leaves zero-dimension dock splitters and crashes the Dart
209
+ // scatter plot's smart-labels with
210
+ // "Failed to execute 'drawImage'... canvas with width or height of 0".
211
+ grok.shell.v = tv;
212
+ const chartHost = tv;
213
+
214
+ // D-14 — surface the smart-filter transform stats above the grid when it
215
+ // actually dropped rows. Tag is set only by runEnrichmentPipeline when
216
+ // kept < total; the banner copy is locked in 14-UI-SPEC.md §"Copywriting".
217
+ if (enrichDf.getTag('proteomics.enrichment_smart_filtered') === 'true') {
218
+ const kept = enrichDf.getTag('proteomics.enrichment_smart_filtered_kept') ?? '?';
219
+ const total = enrichDf.getTag('proteomics.enrichment_smart_filtered_total') ?? '?';
220
+ const parents = enrichDf.getTag('proteomics.enrichment_smart_filtered_dropped_parents') ?? '?';
221
+ const cap = enrichDf.getTag('proteomics.enrichment_smart_filtered_cap') ?? '15';
222
+ const banner = ui.divText(
223
+ `Smart pathway filter active: showing ${kept} of ${total} terms ` +
224
+ `(dropped ${parents} generic parents, capped at ${cap} per source). ` +
225
+ `Re-run with the filter off to see all.`);
226
+ banner.dataset.smartFilterBanner = 'true';
227
+ tv.dockManager.dock(banner, DG.DOCK_TYPE.TOP, null, 'Smart Filter Info', 0.1);
228
+ }
229
+
230
+ // Dock the dot/bar charts (extracted so the publish path reuses the exact
231
+ // 2×2) and cross-link the frame. Every chart binds to `enrichDf` itself, so
232
+ // the single full-frame wiring covers all of them (and keeps the Intersection
233
+ // column the Phase-9 wiring keys on).
234
+ dockEnrichmentCharts(chartHost, enrichDf);
235
+ activeSubscriptions.push(wireEnrichmentToVolcano(enrichDf, proteinDf));
236
+
237
+ // The dot/bar charts, the merged enrichment grid, and the smart-filter banner
238
+ // all share this enrichment tab; the volcano stays on its own (protein) tab.
239
+ // The cross-link keeps the two tabs in sync on selection.
240
+ return tv;
241
+ }
242
+
243
+ /** Docks the directional (Up|Down 2×2) or single Dot+Bar enrichment charts onto
244
+ * `host`. Every chart binds to `enrichDf` itself — the derived `negLog10FDR` /
245
+ * `~enrichChartTop` columns are added to that frame and the Up/Down split is a
246
+ * per-viewer formula filter. No cloned subsets, so nothing leaks into a
247
+ * published project's table tree and the charts survive a project round-trip
248
+ * (docked viewers rebind to their host frame on reopen). Pure layout — no
249
+ * subscriptions, no focus changes — shared by the live and publish paths. */
250
+ export function dockEnrichmentCharts(host: DG.TableView, enrichDf: DG.DataFrame): void {
251
+ prepareEnrichmentChartColumns(enrichDf);
252
+
253
+ if (hasDirection(enrichDf, 'Up') && hasDirection(enrichDf, 'Down')) {
254
+ // Balanced 2×2: [Up Dot | Down Dot] over [Up Bar | Down Bar]. Split the
255
+ // chart region into the two columns FIRST, THEN drop each bar under its dot
256
+ // — docking the bars before the split carved up only the top-left cell and
257
+ // produced a lopsided layout.
258
+ const upDotNode = host.dockManager.dock(
259
+ createEnrichmentDotPlot(enrichDf, 'Up'), DG.DOCK_TYPE.RIGHT, null, 'Up — Dot Plot', 0.6);
260
+ const downDotNode = host.dockManager.dock(
261
+ createEnrichmentDotPlot(enrichDf, 'Down'), DG.DOCK_TYPE.RIGHT, upDotNode, 'Down — Dot Plot', 0.5);
262
+ host.dockManager.dock(
263
+ createEnrichmentBarChart(enrichDf, 'Up'), DG.DOCK_TYPE.DOWN, upDotNode, 'Up — Bar Chart', 0.5);
264
+ host.dockManager.dock(
265
+ createEnrichmentBarChart(enrichDf, 'Down'), DG.DOCK_TYPE.DOWN, downDotNode, 'Down — Bar Chart', 0.5);
266
+ return;
267
+ }
268
+
269
+ // Single-direction or no Direction column → one Dot + Bar.
270
+ const dotNode = host.dockManager.dock(
271
+ createEnrichmentDotPlot(enrichDf), DG.DOCK_TYPE.RIGHT, null, 'Dot Plot', 0.5);
272
+ host.dockManager.dock(
273
+ createEnrichmentBarChart(enrichDf), DG.DOCK_TYPE.DOWN, dotNode, 'Bar Chart', 0.5);
274
+ }
@@ -0,0 +1,218 @@
1
+ import * as DG from 'datagrok-api/dg';
2
+ import {findColumn} from '../utils/column-detection';
3
+ import {SEMTYPE} from '../utils/proteomics-types';
4
+ import {getGroups, GroupAssignment} from '../analysis/experiment-setup';
5
+
6
+ /**
7
+ * R4 / D-12 — Group-Mean Correlation viewer.
8
+ *
9
+ * Adds two derived columns to the host DataFrame (Numerator Mean, Denominator
10
+ * Mean — means of the group1/group2 intensity columns per row) and renders
11
+ * them as a scatter colored by the existing `direction` column (magenta/cyan/
12
+ * gray per D-04). The viewer title carries inline Pearson r and Spearman ρ
13
+ * over the current filter; a y=x diagonal reference line is drawn in #888888.
14
+ *
15
+ * The derived columns are re-run-safe via `ensureFreshFloat` — a second
16
+ * invocation replaces the columns instead of duplicating them.
17
+ *
18
+ * Statistics: `@datagrok-libraries/statistics` exports only Kendall's tau,
19
+ * so Pearson and Spearman are implemented inline (Pearson over raw values,
20
+ * Spearman = Pearson over fractional ranks).
21
+ */
22
+
23
+ const NUMERATOR_MEAN_COL = 'Numerator Mean';
24
+ const DENOMINATOR_MEAN_COL = 'Denominator Mean';
25
+ const DIRECTION_COL = 'direction';
26
+ const DIAGONAL_FORMULA = `\${${NUMERATOR_MEAN_COL}} = \${${DENOMINATOR_MEAN_COL}}`;
27
+ const DIAGONAL_COLOR = '#888888';
28
+
29
+ /** Bulk-init pattern — removes an existing column first to avoid duplicates on
30
+ * re-run. Mirrors src/viewers/qc-computations.ts:28-32. */
31
+ function ensureFreshFloat(df: DG.DataFrame, name: string): DG.Column {
32
+ if (df.columns.contains(name))
33
+ df.columns.remove(name);
34
+ return df.columns.addNewFloat(name);
35
+ }
36
+
37
+ /** Mean of non-null values across `cols` at `rowIdx`. Returns NaN if every
38
+ * value is null. Mirrors qc-computations.ts:15-25. */
39
+ function groupMean(cols: DG.Column[], rowIdx: number): number {
40
+ let sum = 0;
41
+ let n = 0;
42
+ for (const c of cols) {
43
+ if (c.isNone(rowIdx)) continue;
44
+ sum += c.get(rowIdx) as number;
45
+ n++;
46
+ }
47
+ return n > 0 ? sum / n : NaN;
48
+ }
49
+
50
+ /**
51
+ * Derives Numerator Mean (group1) and Denominator Mean (group2) columns on
52
+ * `df`. The join key is the protein row index — never a derived key like
53
+ * gene name (project memory project_proteomics_deqms_result_misalignment).
54
+ *
55
+ * The columns carry the dedicated SEMTYPE.NUMERATOR_MEAN / SEMTYPE.DENOMINATOR_MEAN
56
+ * tags so downstream tooling can find them via `findColumn`.
57
+ */
58
+ export function computeGroupMeans(df: DG.DataFrame, groups: GroupAssignment): void {
59
+ const g1Cols = groups.group1.columns
60
+ .map((n) => df.col(n))
61
+ .filter((c): c is DG.Column => c != null);
62
+ const g2Cols = groups.group2.columns
63
+ .map((n) => df.col(n))
64
+ .filter((c): c is DG.Column => c != null);
65
+
66
+ const nRows = df.rowCount;
67
+ const numArr = new Float32Array(nRows);
68
+ const denArr = new Float32Array(nRows);
69
+ for (let i = 0; i < nRows; i++) {
70
+ const m1 = groupMean(g1Cols, i);
71
+ const m2 = groupMean(g2Cols, i);
72
+ numArr[i] = isNaN(m1) ? DG.FLOAT_NULL : m1;
73
+ denArr[i] = isNaN(m2) ? DG.FLOAT_NULL : m2;
74
+ }
75
+
76
+ const numCol = ensureFreshFloat(df, NUMERATOR_MEAN_COL);
77
+ numCol.init((i) => numArr[i]);
78
+ numCol.semType = SEMTYPE.NUMERATOR_MEAN;
79
+
80
+ const denCol = ensureFreshFloat(df, DENOMINATOR_MEAN_COL);
81
+ denCol.init((i) => denArr[i]);
82
+ denCol.semType = SEMTYPE.DENOMINATOR_MEAN;
83
+ }
84
+
85
+ /** Pearson correlation. Skips paired entries containing NaN or FLOAT_NULL. */
86
+ export function pearson(xs: number[], ys: number[]): number {
87
+ let n = 0;
88
+ let sumX = 0;
89
+ let sumY = 0;
90
+ for (let i = 0; i < xs.length; i++) {
91
+ const x = xs[i];
92
+ const y = ys[i];
93
+ if (!Number.isFinite(x) || !Number.isFinite(y) || x === DG.FLOAT_NULL || y === DG.FLOAT_NULL) continue;
94
+ sumX += x;
95
+ sumY += y;
96
+ n++;
97
+ }
98
+ if (n === 0) return NaN;
99
+ const meanX = sumX / n;
100
+ const meanY = sumY / n;
101
+ let num = 0;
102
+ let denX = 0;
103
+ let denY = 0;
104
+ for (let i = 0; i < xs.length; i++) {
105
+ const x = xs[i];
106
+ const y = ys[i];
107
+ if (!Number.isFinite(x) || !Number.isFinite(y) || x === DG.FLOAT_NULL || y === DG.FLOAT_NULL) continue;
108
+ const dx = x - meanX;
109
+ const dy = y - meanY;
110
+ num += dx * dy;
111
+ denX += dx * dx;
112
+ denY += dy * dy;
113
+ }
114
+ const den = Math.sqrt(denX * denY);
115
+ return den === 0 ? NaN : num / den;
116
+ }
117
+
118
+ /** Fractional ranks (1-based). Ties get the average of the positions they span. */
119
+ function rank(arr: number[]): number[] {
120
+ const indexed = arr.map((v, i) => ({v, i}));
121
+ indexed.sort((a, b) => a.v - b.v);
122
+ const ranks = new Array<number>(arr.length);
123
+ let i = 0;
124
+ while (i < indexed.length) {
125
+ let j = i;
126
+ while (j + 1 < indexed.length && indexed[j + 1].v === indexed[i].v) j++;
127
+ const avg = (i + j) / 2 + 1; // 1-based average of positions [i..j]
128
+ for (let k = i; k <= j; k++) ranks[indexed[k].i] = avg;
129
+ i = j + 1;
130
+ }
131
+ return ranks;
132
+ }
133
+
134
+ /** Spearman = Pearson over fractional ranks. */
135
+ export function spearman(xs: number[], ys: number[]): number {
136
+ return pearson(rank(xs), rank(ys));
137
+ }
138
+
139
+ /** Computes Pearson + Spearman over the filtered subset of two columns. */
140
+ function computePearsonSpearman(
141
+ df: DG.DataFrame, xName: string, yName: string,
142
+ ): {r: number; rho: number} {
143
+ const xCol = df.col(xName);
144
+ const yCol = df.col(yName);
145
+ if (!xCol || !yCol) return {r: NaN, rho: NaN};
146
+ const xRaw = xCol.getRawData() as Float32Array | Float64Array;
147
+ const yRaw = yCol.getRawData() as Float32Array | Float64Array;
148
+
149
+ const xs: number[] = [];
150
+ const ys: number[] = [];
151
+ for (let i = 0; i < df.rowCount; i++) {
152
+ if (!df.filter.get(i)) continue;
153
+ const x = xRaw[i];
154
+ const y = yRaw[i];
155
+ if (x === DG.FLOAT_NULL || y === DG.FLOAT_NULL) continue;
156
+ if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
157
+ xs.push(x);
158
+ ys.push(y);
159
+ }
160
+ return {r: pearson(xs, ys), rho: spearman(xs, ys)};
161
+ }
162
+
163
+ /**
164
+ * Main factory — derives Numerator/Denominator Mean, plots them as a scatter
165
+ * colored by direction, draws the y=x diagonal, and writes an inline
166
+ * Pearson/Spearman annotation into the title.
167
+ */
168
+ export function createGroupMeanCorrelation(
169
+ df: DG.DataFrame,
170
+ options?: {title?: string},
171
+ ): DG.ScatterPlotViewer {
172
+ const groups = getGroups(df);
173
+ if (!groups)
174
+ throw new Error('Annotate experimental groups first (Proteomics | Annotate Experiment)');
175
+
176
+ computeGroupMeans(df, groups);
177
+
178
+ const sp = df.plot.scatter({
179
+ x: NUMERATOR_MEAN_COL,
180
+ y: DENOMINATOR_MEAN_COL,
181
+ color: DIRECTION_COL,
182
+ });
183
+
184
+ // Display Name primary, Gene name fallback (Plan 02 pattern).
185
+ const labelCol = findColumn(df, SEMTYPE.DISPLAY_NAME, ['display name']) ??
186
+ findColumn(df, SEMTYPE.GENE_SYMBOL, ['gene name', 'gene symbol']);
187
+ if (labelCol) {
188
+ sp.props.labelColumnNames = [labelCol.name];
189
+ sp.props.showLabelsFor = 'MouseOverRow';
190
+ }
191
+
192
+ // y = x diagonal reference — replace any prior diagonal so re-run doesn't stack.
193
+ df.meta.formulaLines.items = df.meta.formulaLines.items.filter((line) => {
194
+ const f = (line.formula ?? '') as string;
195
+ return !(typeof f === 'string' &&
196
+ f.includes(NUMERATOR_MEAN_COL) && f.includes(DENOMINATOR_MEAN_COL));
197
+ });
198
+ df.meta.formulaLines.addLine({
199
+ formula: DIAGONAL_FORMULA,
200
+ color: DIAGONAL_COLOR,
201
+ width: 1,
202
+ visible: true,
203
+ });
204
+ sp.props.showViewerFormulaLines = true;
205
+
206
+ // Inline correlation annotation over the current filter.
207
+ const {r, rho} = computePearsonSpearman(df, NUMERATOR_MEAN_COL, DENOMINATOR_MEAN_COL);
208
+ const baseTitle = options?.title ?? 'Group-Mean Correlation';
209
+ const rStr = Number.isFinite(r) ? r.toFixed(2) : 'NA';
210
+ const rhoStr = Number.isFinite(rho) ? rho.toFixed(2) : 'NA';
211
+ sp.setOptions({
212
+ title: `${baseTitle} — r=${rStr} (Pearson), ρ=${rhoStr} (Spearman)`,
213
+ xColumnLabel: `${groups.group1.name} mean (log2 intensity)`,
214
+ yColumnLabel: `${groups.group2.name} mean (log2 intensity)`,
215
+ } as any);
216
+
217
+ return sp;
218
+ }
@@ -0,0 +1,168 @@
1
+ import * as DG from 'datagrok-api/dg';
2
+ import {getGroups} from '../analysis/experiment-setup';
3
+ import {findColumn} from '../utils/column-detection';
4
+ import {SEMTYPE} from '../utils/proteomics-types';
5
+
6
+ /** Z-score normalize a value given mean and standard deviation. */
7
+ function zScore(value: number, mean: number, std: number): number {
8
+ return std > 0 ? (value - mean) / std : 0;
9
+ }
10
+
11
+ /**
12
+ * Creates an expression heatmap (Grid in heatmap mode) on a cloned DataFrame
13
+ * containing only the top-N most significant proteins. The clone isolates
14
+ * the heatmap's filter, z-score columns, and sort order from the original
15
+ * DataFrame so that other viewers (volcano plot) are not affected.
16
+ *
17
+ * Rows are hierarchically clustered via the Dendrogram package service if available,
18
+ * otherwise sorted by adj.p-value ascending (significance-based fallback).
19
+ *
20
+ * Z-score normalization uses statistics computed across ALL rows of the original
21
+ * DataFrame for statistical correctness, but z-score columns are only added to the
22
+ * cloned DataFrame.
23
+ */
24
+ export async function createExpressionHeatmap(
25
+ df: DG.DataFrame,
26
+ options?: {topN?: number; labelCol?: string; title?: string},
27
+ ): Promise<DG.Grid> {
28
+ const topN = options?.topN ?? 50;
29
+ const labelColName = options?.labelCol ?? 'Gene Name';
30
+
31
+ // 1. Check prerequisites
32
+ if (df.getTag('proteomics.de_complete') !== 'true')
33
+ throw new Error('Differential expression must be run first');
34
+
35
+ const groups = getGroups(df);
36
+ if (!groups)
37
+ throw new Error('Experimental groups must be annotated first');
38
+
39
+ // 2. Select top N proteins by adj.p-value (ascending)
40
+ const adjPCol = df.col('adj.p-value');
41
+ if (!adjPCol)
42
+ throw new Error('adj.p-value column not found');
43
+
44
+ // Collect row indices with non-null adj.p-value, sorted ascending
45
+ const indexedPValues: {idx: number; pVal: number}[] = [];
46
+ for (let i = 0; i < df.rowCount; i++) {
47
+ if (!adjPCol.isNone(i))
48
+ indexedPValues.push({idx: i, pVal: adjPCol.get(i) as number});
49
+ }
50
+ indexedPValues.sort((a, b) => a.pVal - b.pVal);
51
+
52
+ const topNIndices = new Set<number>();
53
+ for (let i = 0; i < Math.min(topN, indexedPValues.length); i++)
54
+ topNIndices.add(indexedPValues[i].idx);
55
+
56
+ // 3. Clone the DataFrame with only top-N rows (filter isolation)
57
+ const filter = DG.BitSet.create(df.rowCount);
58
+ for (const idx of topNIndices)
59
+ filter.set(idx, true);
60
+ const heatmapDf = df.clone(filter);
61
+
62
+ // 4. Compute z-score columns on the cloned DataFrame
63
+ // Statistics (mean, std) are computed across ALL rows of the original df for correctness,
64
+ // then z-scores are computed on the cloned rows only.
65
+ const allIntensityCols = [...groups.group1.columns, ...groups.group2.columns];
66
+ const zScoreColNames: string[] = [];
67
+
68
+ for (const colName of allIntensityCols) {
69
+ const zColName = `_zscore_${colName}`;
70
+ zScoreColNames.push(zColName);
71
+
72
+ const srcColOrig = df.col(colName);
73
+ if (!srcColOrig) continue;
74
+
75
+ // Compute mean and std across all non-null values in the ORIGINAL column.
76
+ // Use direct typed-array access to skip the per-row JS->native interop.
77
+ const origRaw = srcColOrig.getRawData() as Float32Array | Float64Array;
78
+ let sum = 0;
79
+ let sumSq = 0;
80
+ let count = 0;
81
+ for (let i = 0; i < df.rowCount; i++) {
82
+ const v = origRaw[i];
83
+ if (v !== DG.FLOAT_NULL) {
84
+ sum += v;
85
+ sumSq += v * v;
86
+ count++;
87
+ }
88
+ }
89
+
90
+ const colMean = count > 0 ? sum / count : 0;
91
+ const variance = count > 1 ? (sumSq - sum * sum / count) / (count - 1) : 0;
92
+ const colStd = Math.sqrt(variance);
93
+
94
+ // Apply z-scores to the cloned DataFrame
95
+ const srcColClone = heatmapDf.col(colName);
96
+ if (!srcColClone) continue;
97
+
98
+ const srcRaw = srcColClone.getRawData() as Float32Array | Float64Array;
99
+ const zCol = heatmapDf.columns.addNewFloat(zColName);
100
+ zCol.init((i) => srcRaw[i] === DG.FLOAT_NULL ? DG.FLOAT_NULL : zScore(srcRaw[i], colMean, colStd));
101
+ }
102
+
103
+ // 5. Create Grid on the cloned DataFrame
104
+ const grid = heatmapDf.plot.grid();
105
+
106
+ // 6. Column visibility -- hide everything, then show label + z-score columns
107
+ for (let i = 0; i < grid.columns.length; i++) {
108
+ const gc = grid.columns.byIndex(i);
109
+ if (gc) gc.visible = false;
110
+ }
111
+
112
+ // Show label column (gene symbol preferred, protein ID as fallback)
113
+ const labelCol = findColumn(heatmapDf, SEMTYPE.GENE_SYMBOL, [labelColName.toLowerCase(), 'gene symbol'])
114
+ ?? findColumn(heatmapDf, SEMTYPE.PROTEIN_ID, ['protein id', 'majority protein id', 'accession']);
115
+ if (labelCol) {
116
+ const labelGc = grid.columns.byName(labelCol.name);
117
+ if (labelGc) labelGc.visible = true;
118
+ }
119
+
120
+ // Show z-score columns in group order (control left, treatment right)
121
+ const group1ZCols = groups.group1.columns.map((c) => `_zscore_${c}`);
122
+ const group2ZCols = groups.group2.columns.map((c) => `_zscore_${c}`);
123
+
124
+ for (const zColName of [...group1ZCols, ...group2ZCols]) {
125
+ const gc = grid.columns.byName(zColName);
126
+ if (gc) gc.visible = true;
127
+ }
128
+
129
+ // 7. Attempt hierarchical clustering via Dendrogram package; fall back to
130
+ // significance-based row ordering if the package is unavailable or its
131
+ // function signature has drifted.
132
+ let clustered = false;
133
+ const pi = DG.TaskBarProgressIndicator.create('Calculating distance matrix...');
134
+ try {
135
+ const dFunc = DG.Func.find({package: 'Dendrogram', name: 'hierarchicalClustering'})[0];
136
+ if (!dFunc)
137
+ throw new Error('Dendrogram package not installed (hierarchicalClustering not found)');
138
+ if (dFunc.inputs.length !== 4)
139
+ throw new Error(`Dendrogram.hierarchicalClustering signature changed (expected 4 inputs, got ${dFunc.inputs.length})`);
140
+ await dFunc.apply({
141
+ df: grid, colNameList: zScoreColNames,
142
+ distance: 'euclidean', linkage: 'complete',
143
+ });
144
+ clustered = true;
145
+ } catch (e: any) {
146
+ console.warn(`Dendrogram clustering unavailable, falling back to significance-based row ordering: ${e?.message ?? e}`);
147
+ } finally {
148
+ pi.close();
149
+ }
150
+
151
+ if (!clustered) {
152
+ // Sort by adjusted p-value ascending (most significant first). R scripts
153
+ // sometimes return the column as `adj.p.value` (dots) instead of `adj.p-value`.
154
+ const sortCol = heatmapDf.col('adj.p-value') ? 'adj.p-value' :
155
+ (heatmapDf.col('adj.p.value') ? 'adj.p.value' : null);
156
+ if (sortCol)
157
+ grid.sort([sortCol]);
158
+ }
159
+
160
+ // 8. Configure Grid for heatmap display
161
+ grid.props.isHeatmap = true;
162
+ grid.props.isGrid = false;
163
+
164
+ if (options?.title)
165
+ grid.setOptions({title: options.title});
166
+
167
+ return grid;
168
+ }