@datagrok/proteomics 1.0.1 → 1.2.1

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 +88 -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 +2073 -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
package/src/package.ts CHANGED
@@ -1,17 +1,1029 @@
1
- /* eslint-disable max-len */
2
- /* Do not change these import lines to match external modules in webpack configuration */
3
- import * as grok from 'datagrok-api/grok';
4
- import * as ui from 'datagrok-api/ui';
5
- import * as DG from 'datagrok-api/dg';
6
- export * from './package.g';
7
-
8
- export const _package = new DG.Package();
9
-
10
- //name: info
11
- export function info() {
12
- grok.shell.info(_package.webRoot);
13
- }
14
-
15
- export class PackageFunctions {
16
-
17
- }
1
+ /* Do not change these import lines. Datagrok will import API library in exactly the same manner */
2
+ import * as grok from 'datagrok-api/grok';
3
+ import * as ui from 'datagrok-api/ui';
4
+ import * as DG from 'datagrok-api/dg';
5
+ import * as rxjs from 'rxjs';
6
+ import {debounceTime} from 'rxjs/operators';
7
+
8
+ import {parseMaxQuantText} from './parsers/maxquant-parser';
9
+ import {parseSpectronautText, parseSpectronautStream} from './parsers/spectronaut-parser';
10
+ import {parseSpectronautCandidatesText, COMPARISON_COLUMNS}
11
+ from './parsers/spectronaut-candidates-parser';
12
+ import {parseFragPipeText} from './parsers/fragpipe-parser';
13
+ import {showGenericImportDialog} from './parsers/generic-parser';
14
+ import {showAnnotationDialog, getGroups, getOrganism, setOrganism} from './analysis/experiment-setup';
15
+ import {detectOrganismCode} from './utils/organisms';
16
+ import {computeSpcMetrics, evaluateNelsonRulesAllMetrics, setSpcStatus, getRunMeta,
17
+ defaultRulesEnabledAllMetrics, BaselineSnapshot} from './analysis/spc';
18
+ import {upsertRun, loadRuns, loadBaseline, SpcRunRow} from './analysis/spc-storage';
19
+ import {showNormalizationDialog, quantileNormalize, vsnNormalize} from './analysis/normalization';
20
+ import {showLog2ScaleDialog} from './analysis/log2-scale';
21
+ import {showEnrichmentInputExportDialog} from './analysis/enrichment-export';
22
+ import {showImputationDialog, imputeKnn, imputeZero, imputeMean, imputeMedian} from './analysis/imputation';
23
+ import {showDEDialog, requireDifferentialExpression} from './analysis/differential-expression';
24
+ import {createVolcanoPlot, recomputeVolcano, readVolcanoState, MetricKind, applyTopNLabels,
25
+ getVolcanoTopN, showVolcanoBusy, updateVolcanoBusy, hideVolcanoBusy,
26
+ getVolcanoAxisMax, setVolcanoAxisMax,
27
+ LOCATION_COL} from './viewers/volcano';
28
+ import {STORE as SUBCELL_STORE} from './analysis/subcellular-location';
29
+ import {createExpressionHeatmap} from './viewers/heatmap';
30
+ import {createPcaPlot} from './viewers/pca-plot';
31
+ import {openQcDashboard} from './viewers/qc-dashboard';
32
+ import {openSpcDashboard} from './viewers/spc-dashboard';
33
+ import {createGroupMeanCorrelation} from './viewers/group-mean-correlation';
34
+ import {uniprotPanel} from './panels/uniprot-panel';
35
+ import {focusProtein} from './panels/protein-focus';
36
+ import {publishedAnalysisPanel} from './panels/published-analysis-panel';
37
+ import {showShareForReviewDialog} from './publishing/share-dialog';
38
+ import {recoverPublishedProject} from './publishing/post-open-recovery';
39
+ import {isPublished} from './publishing/publish-state';
40
+ import {showEnrichmentDialog} from './analysis/enrichment';
41
+ import {openEnrichmentVisualization} from './viewers/enrichment-viewers';
42
+ import {findColumn} from './utils/column-detection';
43
+ import {SEMTYPE, DEFAULT_FC_THRESHOLD, DEFAULT_P_THRESHOLD} from './utils/proteomics-types';
44
+ import {buildProteomicsRibbonMenu} from './menu';
45
+ import {runProteomicsDemo} from './demo/proteomics-demo';
46
+ import {runEnrichmentDemo} from './demo/enrichment-demo';
47
+
48
+ export const _package = new DG.Package();
49
+ export * from './package.g';
50
+
51
+ /** Temporary polyfill */
52
+
53
+ function getDecoratorFunc() {
54
+ return function(args: any) {
55
+ return function(
56
+ target: any,
57
+ propertyKey: string,
58
+ descriptor: PropertyDescriptor,
59
+ ) { };
60
+ };
61
+ }
62
+
63
+ if (!grok.decorators)
64
+ (grok as any).decorators = {};
65
+
66
+ const decorators = [
67
+ 'func', 'init', 'param', 'panel', 'editor', 'demo', 'app',
68
+ 'appTreeBrowser', 'fileHandler', 'fileExporter', 'model', 'viewer', 'filter', 'cellRenderer', 'autostart',
69
+ 'dashboard', 'folderViewer', 'semTypeDetector', 'packageSettingsEditor', 'functionAnalysis', 'converter',
70
+ 'fileViewer', 'treeBrowser', 'polyfill',
71
+ ];
72
+
73
+ decorators.forEach((decorator) => {
74
+ if (!(grok.decorators as any)[decorator])
75
+ (grok.decorators as any)[decorator] = getDecoratorFunc();
76
+ });
77
+
78
+ /** End temporary polyfill */
79
+
80
+
81
+ /** Guard for menu handlers that operate on per-sample intensity data. Returns
82
+ * false (with a clear warning) when the open DataFrame is a pre-computed DE
83
+ * output like a Spectronaut Candidates report — those have no per-sample
84
+ * intensity columns, no group annotations, and no raw quantification to feed
85
+ * into Annotate / Normalize / Impute / DE / Heatmap / PCA / QC. */
86
+ function requireSampleLevelData(df: DG.DataFrame, action: string): boolean {
87
+ const source = df.getTag('proteomics.source');
88
+ if (source === 'spectronaut-candidates') {
89
+ grok.shell.warning(
90
+ `${action} needs per-sample intensities, but this table is a Spectronaut Candidates ` +
91
+ `report (one row per protein per comparison — pre-computed DE only). ` +
92
+ `Import the matching Spectronaut Report (Proteomics | Import | Spectronaut Report) ` +
93
+ `to enable per-sample analyses, or stick to Volcano / Enrichment / UniProt panel on this one.`,
94
+ );
95
+ return false;
96
+ }
97
+ return true;
98
+ }
99
+
100
+
101
+ /** Precursor/fragment-level signature columns. A Spectronaut report carrying
102
+ * any of these is a long-format precursor export that must go through the
103
+ * streaming aggregator (the V8 string ceiling makes file.text() OOM on the
104
+ * 2.6 GB real report). A PG-level long report has none of these and keeps the
105
+ * proven file.text() path. */
106
+ const PRECURSOR_SIGNATURE_COLUMNS = ['EG.ModifiedPeptide', 'FG.Charge', 'PEP.StrippedSequence'];
107
+
108
+ /** D-01 header sniff: streams only until the first newline (with a ~1 MB sanity
109
+ * guard), then cancels the reader to release the stream — the rest of the file
110
+ * is never read here. Returns true iff the header carries a precursor signature
111
+ * column, in which case `importSpectronaut` routes to `parseSpectronautStream`. */
112
+ export async function sniffIsPrecursor(file: File): Promise<boolean> {
113
+ const reader = file.stream()
114
+ .pipeThrough(new TextDecoderStream('utf-8'))
115
+ .getReader();
116
+ try {
117
+ let buffer = '';
118
+ const maxBytes = 1024 * 1024; // 1 MB pre-newline sanity guard
119
+ while (buffer.indexOf('\n') < 0 && buffer.length < maxBytes) {
120
+ const {value, done} = await reader.read();
121
+ if (done) break;
122
+ buffer += value;
123
+ }
124
+ const nl = buffer.indexOf('\n');
125
+ let header = nl >= 0 ? buffer.slice(0, nl) : buffer;
126
+ if (header.endsWith('\r')) header = header.slice(0, -1);
127
+ return PRECURSOR_SIGNATURE_COLUMNS.some((c) => header.includes(c));
128
+ } finally {
129
+ await reader.cancel();
130
+ }
131
+ }
132
+
133
+
134
+ /** Module-level subscription store for the unified Filters viewer's
135
+ * search-match → df.selection wiring. Disposed on re-entry so a second
136
+ * multi-contrast import does not leak the prior handler. */
137
+ let activeFilterSubscriptions: rxjs.Subscription[] = [];
138
+
139
+ /** Sets `proteomics.organism` from the data's organism column at import when it
140
+ * resolves to a single supported species, so enrichment and the subcellular-
141
+ * location fetch narrow to the right organism without waiting for a dialog.
142
+ * No-op when already set, absent, or ambiguous (multi-species) — the user then
143
+ * picks in the Annotate/Enrichment dialog. */
144
+ function autoDetectOrganism(df: DG.DataFrame): void {
145
+ if (getOrganism(df)) return;
146
+ const code = detectOrganismCode(df);
147
+ if (code) setOrganism(df, code);
148
+ }
149
+
150
+ /**
151
+ * R4/D-07 + G4 + D-05: when a Spectronaut Candidates file carries more than one
152
+ * distinct Comparison, dock a native Datagrok Filters viewer carrying typed
153
+ * per-column filters: a categorical filter on Comparison plus free-text search
154
+ * boxes on Display Name and Source ID (D-05 unified protein search). A single
155
+ * distinct comparison docks nothing. Shell-only orchestration — the parser
156
+ * stays pure. Returns whether a filter was docked.
157
+ *
158
+ * G4 root-cause fix (14-RESEARCH.md §"Pattern 1" / §"Pitfall 1"):
159
+ * Phase 13 observed that the `columnNames` allowlist silently extends itself
160
+ * with the boolean `Flags` column via the combined-boolean filter. We migrate
161
+ * to the typed `filters` array and set `showBoolCombinedFilter: false` to
162
+ * exclude Flags explicitly. A runtime verification gate falls back to an
163
+ * explicit setOptions() override if the platform still leaks Flags into the
164
+ * look config.
165
+ */
166
+ export function dockComparisonFilterIfMultiContrast(
167
+ tv: DG.TableView, df: DG.DataFrame,
168
+ ): boolean {
169
+ const cmpCol = COMPARISON_COLUMNS.reduce<DG.Column | null>(
170
+ (found, name) => found ?? df.col(name), null);
171
+ if (!cmpCol) return false;
172
+ const distinct = new Set<string>();
173
+ for (let i = 0; i < df.rowCount; i++) {
174
+ const v = cmpCol.get(i);
175
+ if (typeof v === 'string' && v.length > 0) distinct.add(v);
176
+ }
177
+ if (distinct.size <= 1) return false;
178
+
179
+ // Display Name + Source ID come from Plan 14-01's gene-label resolver. On
180
+ // DataFrames predating Plan 01 the columns may be absent — fall back to the
181
+ // Protein ID column so the search-by-gene affordance still works.
182
+ const displayNameCol = findColumn(df, SEMTYPE.DISPLAY_NAME, ['display name']);
183
+ const sourceIdCol = findColumn(df, SEMTYPE.SOURCE_ID, ['source id']);
184
+ const proteinIdCol = findColumn(df, SEMTYPE.PROTEIN_ID,
185
+ ['primary protein id', 'protein id', 'uniprot', 'accession']);
186
+
187
+ // Live UAT (2026-06-04) found the typed `filters: filterSpecs` shape doesn't
188
+ // round-trip through the platform serializer — getOptions().look ends up
189
+ // with ONLY `showBoolCombinedFilter`, no `filters` or `columnNames`, so the
190
+ // viewer docks empty. The legacy `columnNames` array shape is the stable
191
+ // contract: it materializes the requested columns as categorical filters
192
+ // (free-text/categorical type is inferred per column). Display Name +
193
+ // Source ID become string-categorical filters that include a search box
194
+ // each — they replace the typed free-text filters with no UX regression.
195
+ const columnNames: string[] = [cmpCol.name];
196
+ if (displayNameCol) columnNames.push(displayNameCol.name);
197
+ if (sourceIdCol) columnNames.push(sourceIdCol.name);
198
+ if (!displayNameCol && proteinIdCol) columnNames.push(proteinIdCol.name);
199
+
200
+ const filters = DG.Viewer.filters(df, {
201
+ columnNames,
202
+ showBoolCombinedFilter: false, // G4 Flags exclusion (14-RESEARCH §"Pitfall 1")
203
+ showHeader: true,
204
+ showSearchBox: true,
205
+ } as any);
206
+
207
+ tv.dockManager.dock(filters, DG.DOCK_TYPE.RIGHT, null, 'Filters', 0.3);
208
+
209
+ // D-05 search-match wiring: free-text search boxes on Display Name / Source ID
210
+ // mutate df.filter (hide non-matching rows). UI-SPEC requires highlight-not-
211
+ // hide so the NS cloud stays visible. Defensive capture-restore path per
212
+ // 14-RESEARCH §"Open Q 1 (RESOLVED)":
213
+ // 1. Save df.filter into savedBitSet before subscribing.
214
+ // 2. On df.onFilterChanged (debounced): capture matched indices from the
215
+ // current (search-mutated) df.filter.
216
+ // 3. Restore df.filter via copyFrom(savedBitSet) so non-matched rows
217
+ // stay visible.
218
+ // 4. Write df.selection.set(idx) on the matched indices.
219
+ // 5. applyTopNLabels(df, sp, N, matched) so the top-N labels coexist with
220
+ // the search-matched rows (Pitfall 7). Labels are decoupled from
221
+ // selection — step 4's selection is the search highlight only.
222
+ // The `restoring` flag guards re-entry from the copyFrom step.
223
+ // Assumption A5: df.selection.set does NOT re-trigger df.filter.onChanged.
224
+ for (const s of activeFilterSubscriptions) s.unsubscribe();
225
+ activeFilterSubscriptions = [];
226
+
227
+ const findVolcano = (): DG.ScatterPlotViewer | null => {
228
+ for (const v of tv.viewers) {
229
+ if (v.type === DG.VIEWER.SCATTER_PLOT &&
230
+ (v as DG.ScatterPlotViewer).props.yColumnName === 'negLog10P')
231
+ return v as DG.ScatterPlotViewer;
232
+ }
233
+ return null;
234
+ };
235
+
236
+ const savedBitSet = DG.BitSet.create(df.rowCount);
237
+ savedBitSet.copyFrom(df.filter);
238
+ let restoring = false;
239
+
240
+ activeFilterSubscriptions.push(df.onFilterChanged.pipe(debounceTime(100)).subscribe(() => {
241
+ if (restoring) return; // guard against the restore call re-triggering this handler
242
+
243
+ // Capture matched indices from the current (search-mutated) df.filter.
244
+ const matched: number[] = [];
245
+ for (let i = 0; i < df.rowCount; i++) {
246
+ if (df.filter.get(i)) matched.push(i);
247
+ }
248
+
249
+ // If nothing was filtered out (filter equals saved state), the change came
250
+ // from elsewhere — nothing to highlight, just refresh the saved state.
251
+ if (matched.length === df.rowCount) {
252
+ savedBitSet.copyFrom(df.filter);
253
+ return;
254
+ }
255
+
256
+ // Restore df.filter to its pre-search state.
257
+ restoring = true;
258
+ try {
259
+ df.filter.copyFrom(savedBitSet);
260
+ } finally {
261
+ restoring = false;
262
+ }
263
+
264
+ // Write df.selection from the match (Assumption A5: no onFilterChanged re-trigger).
265
+ df.selection.setAll(false, false);
266
+ for (const idx of matched) df.selection.set(idx, true, false);
267
+ df.selection.fireChanged();
268
+
269
+ // Pitfall 7: the top-N labels must coexist with the search match. Labels
270
+ // are decoupled from selection now, so label the top-N PLUS the matched
271
+ // rows (the matched rows also stay highlighted via the selection above).
272
+ const sp = findVolcano();
273
+ if (sp) applyTopNLabels(df, sp, getVolcanoTopN(df), matched);
274
+ }));
275
+
276
+ return true;
277
+ }
278
+
279
+ /**
280
+ * Opens the standard Candidates analysis view: table view, auto-volcano,
281
+ * multi-contrast comparison filter, and protein focus. Extracted from the
282
+ * import handler so the auto-volcano behavior is testable without the file
283
+ * dialog. Returns the created TableView.
284
+ *
285
+ * The volcano is opened HERE because Candidates arrive with the DE result
286
+ * already computed (the parser sets `proteomics.de_complete`), so the pipeline's
287
+ * DE step — where the Report path auto-opens the volcano — never runs. Opening
288
+ * it here lands both entry points on the same primary deliverable. Best-effort:
289
+ * skip silently if the volcano's columns are somehow absent, exactly as the
290
+ * DE-completion path tolerates.
291
+ */
292
+ export function openCandidatesAnalysisView(df: DG.DataFrame): DG.TableView {
293
+ const tv = grok.shell.addTableView(df);
294
+ grok.shell.info(`Imported ${df.rowCount} candidates from Spectronaut`);
295
+ try {
296
+ tv.addViewer(createVolcanoPlot(df));
297
+ } catch (volcanoErr: any) {
298
+ grok.shell.warning(
299
+ `Imported, but could not auto-open the volcano: ${volcanoErr?.message ?? volcanoErr}. ` +
300
+ `Open it via Proteomics | Visualize | Volcano Plot.`);
301
+ }
302
+ dockComparisonFilterIfMultiContrast(tv, df);
303
+ focusProtein(df);
304
+ return tv;
305
+ }
306
+
307
+ export class PackageFunctions {
308
+ @grok.decorators.init({tags: ['init']})
309
+ static async initProteomics(): Promise<void> {
310
+ }
311
+
312
+ /**
313
+ * Adds the dynamic Proteomics analysis groups (Annotate / Analyze / Visualize / Share)
314
+ * to a table view's ribbon — see `src/menu.ts`. Import is NOT here; it is a decorator
315
+ * `top-menu` (above) so the platform keeps it in the main left-bar menu and on every
316
+ * ribbon. These groups need `isEnabled` grey-out, which only works on a programmatic
317
+ * menu, so we build them onto the SAME ribbon 'Proteomics' group (get-or-create) the
318
+ * decorator Import created — but only for tables that actually hold Proteomics data.
319
+ *
320
+ * Built once per view (tracked in `done`): `isEnabled` re-evaluates on every menu open,
321
+ * so there is no need to rebuild on view activation — which is what caused the earlier
322
+ * duplicate-menu bug. A view whose table isn't yet a Proteomics table (builder returns
323
+ * false) is left untracked so a later activation retries.
324
+ */
325
+ @grok.decorators.func({tags: ['autostart'], meta: {autostartImmediate: 'true'}})
326
+ static async buildProteomicsTopMenu(): Promise<void> {
327
+ const handlers = {
328
+ importSpectronautCandidates: () => PackageFunctions.importSpectronautCandidates(),
329
+ importSpectronaut: () => PackageFunctions.importSpectronaut(),
330
+ importMaxQuant: () => PackageFunctions.importMaxQuant(),
331
+ importFragPipe: () => PackageFunctions.importFragPipe(),
332
+ importGenericMatrix: () => PackageFunctions.importGenericMatrix(),
333
+ annotateExperiment: () => PackageFunctions.annotateExperiment(),
334
+ setLog2Scale: () => PackageFunctions.setLog2Scale(),
335
+ normalize: () => PackageFunctions.normalizeProteomics(),
336
+ impute: () => PackageFunctions.imputeMissingValues(),
337
+ differentialExpression: () => PackageFunctions.differentialExpression(),
338
+ enrichmentAnalysis: () => PackageFunctions.enrichmentAnalysis(),
339
+ exportEnrichmentInputs: () => PackageFunctions.exportEnrichmentInputs(),
340
+ computeSpcStatus: () => PackageFunctions.computeSpcStatus(),
341
+ showVolcanoPlot: () => PackageFunctions.showVolcanoPlot(),
342
+ showHeatmap: () => PackageFunctions.showHeatmap(),
343
+ showPcaPlot: () => PackageFunctions.showPcaPlot(),
344
+ showGroupMeanCorrelation: () => PackageFunctions.showGroupMeanCorrelation(),
345
+ showQcDashboard: () => PackageFunctions.showQcDashboard(),
346
+ showSpcDashboard: () => PackageFunctions.showSpcDashboard(),
347
+ enrichmentCharts: () => PackageFunctions.enrichmentCharts(),
348
+ showAllVisualizations: () => PackageFunctions.showAllVisualizations(),
349
+ shareAnalysisForReview: () => PackageFunctions.shareAnalysisForReview(),
350
+ };
351
+
352
+ const done = new WeakSet<object>();
353
+ const ensure = (view: any): void => {
354
+ if (view?.type !== DG.VIEW_TYPE.TABLE_VIEW || done.has(view)) return;
355
+ // Defer so the platform has built the decorator Import into the ribbon's Proteomics
356
+ // group first; our get-or-create then attaches to it instead of racing a duplicate.
357
+ setTimeout(() => {
358
+ if (done.has(view)) return;
359
+ try {
360
+ if (buildProteomicsRibbonMenu(view as DG.TableView, handlers))
361
+ done.add(view);
362
+ } catch { /* ribbon not ready — a later activation retries */ }
363
+ }, 50);
364
+ };
365
+
366
+ try { for (const v of grok.shell.views) ensure(v); } catch { /* no views yet */ }
367
+ grok.events.onViewAdded.subscribe(ensure);
368
+ grok.events.onCurrentViewChanged.subscribe(() => ensure(grok.shell.v));
369
+ }
370
+
371
+ // Import lives on decorator `top-menu` (not the dynamic ribbon builder) so it shows in
372
+ // the main Datagrok menu (left bar) as an always-available entry point. Declaration
373
+ // order sets menu order: Candidates first, then Report, then the rest.
374
+ @grok.decorators.func({'top-menu': 'Proteomics | Import | Spectronaut Candidates...'})
375
+ static async importSpectronautCandidates(): Promise<void> {
376
+ DG.Utils.openFile({
377
+ accept: '.tsv,.txt,.csv',
378
+ open: async (file: File) => {
379
+ try {
380
+ const text = await file.text();
381
+ const df = await parseSpectronautCandidatesText(text);
382
+ df.name = file.name.replace(/\.[^.]+$/, '');
383
+ autoDetectOrganism(df);
384
+ openCandidatesAnalysisView(df);
385
+ } catch (e: any) {
386
+ grok.shell.error(`Failed to import Spectronaut Candidates file: ${e?.message ?? e}`);
387
+ }
388
+ },
389
+ });
390
+ }
391
+
392
+ @grok.decorators.func({'top-menu': 'Proteomics | Import | Spectronaut Report...'})
393
+ static async importSpectronaut(): Promise<void> {
394
+ DG.Utils.openFile({
395
+ accept: '.tsv,.txt,.csv',
396
+ open: async (file: File) => {
397
+ try {
398
+ const df = (await sniffIsPrecursor(file)) ?
399
+ await parseSpectronautStream(file) : await parseSpectronautText(await file.text());
400
+ df.name = file.name.replace(/\.[^.]+$/, '');
401
+ autoDetectOrganism(df);
402
+ grok.shell.addTableView(df);
403
+ grok.shell.info(`Imported ${df.rowCount} protein groups from Spectronaut`);
404
+ focusProtein(df);
405
+ } catch (e: any) {
406
+ grok.shell.error(`Failed to import Spectronaut file: ${e?.message ?? e}`);
407
+ grok.shell.warning(
408
+ `If this is a very large precursor-level Spectronaut report that fails to ` +
409
+ `stream in-browser, pre-aggregate it to the importable protein-group shape ` +
410
+ `with the bundled duckdb fallback: tools/spectronaut-aggregate.sh <input.tsv>, ` +
411
+ `then re-import the resulting file. See files/demo/README.md for usage.`,
412
+ );
413
+ }
414
+ },
415
+ });
416
+ }
417
+
418
+ @grok.decorators.func({'top-menu': 'Proteomics | Import | MaxQuant...'})
419
+ static async importMaxQuant(): Promise<void> {
420
+ DG.Utils.openFile({
421
+ accept: '.txt,.tsv',
422
+ open: async (file: File) => {
423
+ try {
424
+ const text = await file.text();
425
+ const df = await parseMaxQuantText(text);
426
+ df.name = file.name.replace(/\.[^.]+$/, '');
427
+ autoDetectOrganism(df);
428
+ grok.shell.addTableView(df);
429
+ grok.shell.info(`Imported ${df.rowCount} protein groups`);
430
+ focusProtein(df);
431
+ } catch (e: any) {
432
+ grok.shell.error(`Failed to import MaxQuant file: ${e?.message ?? e}`);
433
+ }
434
+ },
435
+ });
436
+ }
437
+
438
+ @grok.decorators.func({'top-menu': 'Proteomics | Import | FragPipe...'})
439
+ static async importFragPipe(): Promise<void> {
440
+ DG.Utils.openFile({
441
+ accept: '.tsv,.txt',
442
+ open: async (file: File) => {
443
+ try {
444
+ const text = await file.text();
445
+ const df = await parseFragPipeText(text);
446
+ df.name = file.name.replace(/\.[^.]+$/, '');
447
+ autoDetectOrganism(df);
448
+ grok.shell.addTableView(df);
449
+ grok.shell.info(`Imported ${df.rowCount} protein groups from FragPipe`);
450
+ focusProtein(df);
451
+ } catch (e: any) {
452
+ grok.shell.error(`Failed to import FragPipe file: ${e?.message ?? e}`);
453
+ }
454
+ },
455
+ });
456
+ }
457
+
458
+ @grok.decorators.func({'top-menu': 'Proteomics | Import | Generic Matrix...'})
459
+ static async importGenericMatrix(): Promise<void> {
460
+ showGenericImportDialog();
461
+ }
462
+
463
+ @grok.decorators.func()
464
+ static async annotateExperiment(): Promise<void> {
465
+ const df = grok.shell.tv?.dataFrame;
466
+ if (!df) { grok.shell.warning('No table open'); return; }
467
+ if (!requireSampleLevelData(df, 'Annotate Experiment')) return;
468
+ showAnnotationDialog(df);
469
+ }
470
+
471
+ @grok.decorators.func()
472
+ static async setLog2Scale(): Promise<void> {
473
+ const df = grok.shell.tv?.dataFrame;
474
+ if (!df) { grok.shell.warning('No table open'); return; }
475
+ if (!requireSampleLevelData(df, 'Set Log2 Scale')) return;
476
+ showLog2ScaleDialog(df);
477
+ }
478
+
479
+ @grok.decorators.func()
480
+ static async normalizeProteomics(): Promise<void> {
481
+ const df = grok.shell.tv?.dataFrame;
482
+ if (!df) { grok.shell.warning('No table open'); return; }
483
+ if (!requireSampleLevelData(df, 'Normalize')) return;
484
+ showNormalizationDialog(df);
485
+ }
486
+
487
+ @grok.decorators.func()
488
+ static async imputeMissingValues(): Promise<void> {
489
+ const df = grok.shell.tv?.dataFrame;
490
+ if (!df) { grok.shell.warning('No table open'); return; }
491
+ if (!requireSampleLevelData(df, 'Impute Missing Values')) return;
492
+ showImputationDialog(df);
493
+ }
494
+
495
+ @grok.decorators.func()
496
+ static async differentialExpression(): Promise<void> {
497
+ const tv = grok.shell.tv;
498
+ const df = tv?.dataFrame;
499
+ if (!tv || !df) { grok.shell.warning('No table open'); return; }
500
+ if (!requireSampleLevelData(df, 'Differential Expression')) return;
501
+ showDEDialog(df, () => {
502
+ // Auto-open volcano plot after DE completes. Title is synthesized by
503
+ // createVolcanoPlot from proteomics.groups per the G1 contract.
504
+ const sp = createVolcanoPlot(df);
505
+ tv.addViewer(sp);
506
+ });
507
+ }
508
+
509
+ @grok.decorators.func()
510
+ static async computeSpcStatus(): Promise<void> {
511
+ const tv = grok.shell.tv;
512
+ const df = tv?.dataFrame;
513
+ if (!tv || !df) {
514
+ grok.shell.warning('Open an analyzed file first, then run Compute SPC Status.');
515
+ return;
516
+ }
517
+ if (df.getTag('proteomics.source') === 'spectronaut-candidates') {
518
+ grok.shell.warning('SPC requires per-sample intensities. Re-import this analysis ' +
519
+ 'from the Spectronaut PG report (not the Candidates report) to compute SPC.');
520
+ return;
521
+ }
522
+ const runMeta = getRunMeta(df);
523
+ if (!runMeta || !runMeta.instrument_id || !runMeta.acquisition_datetime) {
524
+ grok.shell.warning('Open Annotate Experiment to set instrument + acquisition datetime.');
525
+ return;
526
+ }
527
+ const groups = getGroups(df);
528
+ if (!groups) {
529
+ grok.shell.warning('Annotate experimental groups first (Proteomics → Annotate Experiment) ' +
530
+ '— SPC needs Group 1 to compute control-replicate correlation.');
531
+ return;
532
+ }
533
+ if (groups.group1.columns.length < 2) {
534
+ grok.shell.info("Group 1 has fewer than 2 samples — control-replicate correlation can't " +
535
+ 'be computed. The other three metrics will still be recorded.');
536
+ }
537
+
538
+ const pi = DG.TaskBarProgressIndicator.create('Computing SPC status...');
539
+ try {
540
+ const metrics = computeSpcMetrics(df, groups, runMeta);
541
+ const baseline = await loadBaseline(runMeta.instrument_id);
542
+ const priorRuns = await loadRuns(runMeta.instrument_id);
543
+ const priorRow = priorRuns.find((r) =>
544
+ r.acquisition_datetime === runMeta.acquisition_datetime);
545
+ const priorStatus = priorRow?.status ?? null;
546
+
547
+ let ruleResult: {status: 'pass' | 'flagged' | 'out_of_control'; rulesTripped: string[]};
548
+ if (baseline === null) {
549
+ ruleResult = {status: 'pass', rulesTripped: []};
550
+ grok.shell.info(`No baseline locked for ${runMeta.instrument_id} yet — recorded as pass. ` +
551
+ 'Define a baseline once at least 4 runs have been computed.');
552
+ } else {
553
+ const priorSeries = {
554
+ median_intensity: priorRuns.map((r) => r.median_intensity),
555
+ missing_pct: priorRuns.map((r) => r.missing_pct),
556
+ control_corr: priorRuns.map((r) => r.control_corr),
557
+ protein_count: priorRuns.map((r) => r.protein_count),
558
+ };
559
+ ruleResult = evaluateNelsonRulesAllMetrics(
560
+ metrics, priorSeries,
561
+ baseline.metrics as BaselineSnapshot,
562
+ baseline.rules_enabled ?? defaultRulesEnabledAllMetrics(),
563
+ );
564
+ }
565
+
566
+ setSpcStatus(df, metrics, ruleResult);
567
+
568
+ const row: Omit<SpcRunRow, 'run_id'> & {run_id?: string} = {
569
+ instrument_id: runMeta.instrument_id,
570
+ acquisition_datetime: runMeta.acquisition_datetime,
571
+ run_label: df.name,
572
+ median_intensity: metrics.median_intensity,
573
+ missing_pct: metrics.missing_pct,
574
+ control_corr: metrics.control_corr,
575
+ protein_count: metrics.protein_count,
576
+ status: ruleResult.status,
577
+ rules_tripped: ruleResult.rulesTripped,
578
+ source_project_id: null,
579
+ source_df_name: df.name,
580
+ computed_at: metrics.computed_at,
581
+ };
582
+ await upsertRun(row);
583
+
584
+ if (priorStatus !== null)
585
+ grok.shell.info(`Updated SPC for ${df.name} (previous status: ${priorStatus}).`);
586
+ else if (ruleResult.status === 'pass')
587
+ grok.shell.info(`SPC computed: ${df.name} — status: pass.`);
588
+ else if (ruleResult.status === 'flagged')
589
+ grok.shell.info(`SPC computed: ${df.name} — flagged on ${ruleResult.rulesTripped.length} ` +
590
+ `rule(s): ${ruleResult.rulesTripped.join(', ')}.`);
591
+ else
592
+ grok.shell.info(`SPC computed: ${df.name} — OUT OF CONTROL on ` +
593
+ `${ruleResult.rulesTripped.length} rule(s): ${ruleResult.rulesTripped.join(', ')}.`);
594
+ } finally {
595
+ pi.close();
596
+ }
597
+ }
598
+
599
+ @grok.decorators.func()
600
+ static async showVolcanoPlot(): Promise<void> {
601
+ const tv = grok.shell.tv;
602
+ const df = tv?.dataFrame;
603
+ if (!tv || !df) { grok.shell.warning('No table open'); return; }
604
+ if (!requireDifferentialExpression(df,
605
+ 'Run Differential Expression first (Proteomics | Analyze | Differential Expression)')) return;
606
+
607
+ // Single menu item: open the options dialog. It reconfigures an existing
608
+ // volcano, or — when none exists yet — CREATES it on OK (so nothing is
609
+ // drawn until the user confirms; Cancel draws nothing). The old separate
610
+ // 'Volcano Options...' item is folded in here.
611
+ await PackageFunctions.volcanoOptions();
612
+ }
613
+
614
+ /** Volcano Plot options dialog. No longer its own menu item — showVolcanoPlot
615
+ * delegates here. Reconfigures the existing volcano, or creates one on OK when
616
+ * none exists. `spArg` is an already-resolved viewer (skips the lookup). */
617
+ static async volcanoOptions(spArg?: DG.ScatterPlotViewer): Promise<void> {
618
+ const tv = grok.shell.tv;
619
+ const df = tv?.dataFrame;
620
+ if (!tv || !df) { grok.shell.warning('No table open'); return; }
621
+ if (!requireDifferentialExpression(df,
622
+ 'Run Differential Expression first (Proteomics | Analyze | Differential Expression)')) return;
623
+
624
+ // The volcano is the scatter plot whose Y is the -log10(metric) column.
625
+ // It may NOT exist yet — in that case sp stays undefined and the OK handler
626
+ // creates it, so the plot is only drawn once the user confirms.
627
+ let sp: DG.ScatterPlotViewer | undefined = spArg;
628
+ if (!sp) {
629
+ for (const v of tv.viewers) {
630
+ if (v.type === DG.VIEWER.SCATTER_PLOT &&
631
+ (v as DG.ScatterPlotViewer).props.yColumnName === 'negLog10P') {
632
+ sp = v as DG.ScatterPlotViewer;
633
+ break;
634
+ }
635
+ }
636
+ }
637
+
638
+ // G2 dialog-state preload: snapshot current viewer state at open time and
639
+ // seed every input from it. Pitfall 2 — getOptions is a snapshot, not a
640
+ // live binding; OK uses the input values, never a re-read. With no existing
641
+ // plot, seed from defaults.
642
+ const state: {metric: MetricKind; colorDim: 'significance' | 'location'} = sp ?
643
+ readVolcanoState(df, sp) : {metric: 'adj.p-value', colorDim: 'significance'};
644
+ const hasPValue = df.col('p-value') != null;
645
+ const initialMetric: MetricKind =
646
+ hasPValue ? state.metric : 'adj.p-value';
647
+ const metricInput = ui.input.choice('Significance metric', {
648
+ value: initialMetric,
649
+ items: hasPValue ? ['adj.p-value', 'p-value'] : ['adj.p-value'],
650
+ nullable: false,
651
+ });
652
+ metricInput.setTooltip(hasPValue ?
653
+ 'Y axis, classification and threshold line all switch together' :
654
+ 'p-value column not present — only adj.p-value is available');
655
+ const colorInput = ui.input.choice('Color by', {
656
+ value: state.colorDim,
657
+ items: ['significance', 'location'],
658
+ nullable: false,
659
+ });
660
+ colorInput.setTooltip(
661
+ 'significance = Enriched in g1 / Enriched in g2 / Not significant; ' +
662
+ 'location = UniProt subcellular location');
663
+ const labelTopNInput = ui.input.int('Label top N points', {
664
+ value: getVolcanoTopN(df),
665
+ min: 0,
666
+ });
667
+ labelTopNInput.setTooltip(
668
+ 'How many of the most significant proteins get name labels. 0 = none. ' +
669
+ 'Labels no longer touch your row selection.');
670
+
671
+ // Optional axis-max overrides so volcanoes from different contrasts can be
672
+ // pinned to a shared scale and compared side-by-side. Empty = auto-scale.
673
+ const axis = getVolcanoAxisMax(df);
674
+ const xMaxInput = ui.input.float('X-axis max (|log2FC|)', {
675
+ value: axis.xMax ?? undefined,
676
+ min: 0,
677
+ nullable: true,
678
+ });
679
+ xMaxInput.setTooltip(
680
+ 'Pin the X axis to ±this |log2FC|. Leave empty to auto-scale. ' +
681
+ 'Set the same value on two volcanoes to compare them side-by-side.');
682
+ const yMaxInput = ui.input.float('Y-axis max (−log10 p)', {
683
+ value: axis.yMax ?? undefined,
684
+ min: 0,
685
+ nullable: true,
686
+ });
687
+ yMaxInput.setTooltip(
688
+ 'Pin the Y axis to 0…this −log10(p). Leave empty to auto-scale. ' +
689
+ 'Set the same value on two volcanoes to compare them side-by-side.');
690
+
691
+ ui.dialog('Volcano Options')
692
+ .add(metricInput)
693
+ .add(colorInput)
694
+ .add(labelTopNInput)
695
+ .add(xMaxInput)
696
+ .add(yMaxInput)
697
+ .onOK(async () => {
698
+ const metric = (metricInput.value ?? 'adj.p-value') as MetricKind;
699
+ const colorDim = colorInput.value === 'location' ? 'location' : 'significance';
700
+
701
+ // Persist the axis-max overrides before recompute — recomputeVolcano
702
+ // ends with applyVolcanoAxisBounds, which reads these tags back.
703
+ setVolcanoAxisMax(df, xMaxInput.value ?? null, yMaxInput.value ?? null);
704
+
705
+ // First-time path: create the volcano NOW (on OK), so it isn't drawn
706
+ // until the user confirms. topNLabels=0 here — applyTopNLabels below
707
+ // sets the chosen count after the metric/color recompute.
708
+ if (!sp) {
709
+ sp = createVolcanoPlot(df, {topNLabels: 0});
710
+ tv.addViewer(sp);
711
+ }
712
+
713
+ // Cache-aware pre-OK toast — the column-present path is sub-second
714
+ // (short-circuit in ensureLocationColumn) so no toast is needed; the
715
+ // column-absent + cold-cache path takes 30-90 s on a real Spectronaut
716
+ // file and the user deserves a heads-up; the column-absent + warm-cache
717
+ // path is a few seconds at most and deserves a shorter toast.
718
+ if (colorDim === 'location' && !df.col(LOCATION_COL)) {
719
+ const cache = await grok.dapi.userDataStorage.get(SUBCELL_STORE).catch(() => null);
720
+ const cacheEntryCount = cache ?
721
+ Object.keys(cache).filter((k) => k !== '__schema_v').length : 0;
722
+ if (cacheEntryCount === 0) {
723
+ grok.shell.info(
724
+ 'Fetching subcellular locations from UniProt — may take a minute or two ' +
725
+ 'on first use without cache; subsequent toggles use the cache.');
726
+ } else {
727
+ grok.shell.info('Resolving subcellular locations from cache.');
728
+ }
729
+ }
730
+
731
+ // G3 wording: progress label is color-specific so a slow Color =
732
+ // Location click reads as classification work, not generic "updating".
733
+ const initialLabel = colorDim === 'location' ?
734
+ 'Classifying subcellular locations…' : 'Updating volcano…';
735
+ const pi = DG.TaskBarProgressIndicator.create(initialLabel);
736
+
737
+ // 13-10: also show progress on the volcano viewer itself — the
738
+ // TaskBarProgressIndicator at the bottom of the platform shell is too
739
+ // easy to miss while staring at a stale-looking chart. Attach when the
740
+ // recompute can actually fetch from UniProt (colorDim === 'location').
741
+ // On the 13-08 warm-cache short-circuit the overlay either flashes for
742
+ // a single frame or appears very briefly; hideVolcanoBusy in finally
743
+ // cleans up either way.
744
+ const willFetchLocation = colorDim === 'location';
745
+ if (willFetchLocation) showVolcanoBusy(sp!, initialLabel);
746
+
747
+ // Map ProgressCb phases onto a human-readable pi.update label.
748
+ const progress = (done: number, total: number, phase: string) => {
749
+ const label = phase === 'fetch-acc' ? 'Fetching subcellular locations' :
750
+ phase === 'fetch-gene' ? 'Resolving by gene name' :
751
+ phase === 'init-column' ? 'Classifying subcellular locations' :
752
+ colorDim === 'location' ? 'Classifying subcellular locations' :
753
+ 'Updating volcano';
754
+ const pct = total > 0 ? Math.round((done / total) * 100) : 0;
755
+ pi.update(pct, `${label}: ${done}/${total}`);
756
+ if (willFetchLocation) {
757
+ const detail = total > 1 ? `${done}/${total} (${pct}%)` : '';
758
+ updateVolcanoBusy(sp!, label, detail);
759
+ }
760
+ };
761
+ try {
762
+ await recomputeVolcano(df, sp!, metric, colorDim,
763
+ DEFAULT_FC_THRESHOLD, DEFAULT_P_THRESHOLD, progress);
764
+ // Re-rank labels against the (possibly new) metric, applying the
765
+ // chosen count. Decoupled from selection — leaves the user's rows be.
766
+ applyTopNLabels(df, sp!, labelTopNInput.value ?? getVolcanoTopN(df));
767
+ } catch (e: any) {
768
+ grok.shell.error(`Volcano update failed: ${e?.message ?? e}`);
769
+ } finally {
770
+ pi.close();
771
+ if (willFetchLocation && sp) hideVolcanoBusy(sp);
772
+ }
773
+ })
774
+ .show();
775
+ }
776
+
777
+ @grok.decorators.func()
778
+ static async showHeatmap(): Promise<void> {
779
+ const tv = grok.shell.tv;
780
+ const df = tv?.dataFrame;
781
+ if (!tv || !df) { grok.shell.warning('No table open'); return; }
782
+ if (!requireSampleLevelData(df, 'Heatmap')) return;
783
+ if (!requireDifferentialExpression(df,
784
+ 'Run Differential Expression first (Proteomics | Analyze | Differential Expression)')) return;
785
+
786
+ // M4 — top-N is now user-configurable (was hardcoded 50). Small dialog so the
787
+ // analyst can widen/narrow the heatmap; the ellipsis menu item implies it.
788
+ const topNInput = ui.input.int('Top N proteins', {value: 50, min: 1});
789
+ topNInput.setTooltip('How many of the most significant proteins (by adj. p-value) to show.');
790
+ ui.dialog('Heatmap Options')
791
+ .add(topNInput)
792
+ .onOK(async () => {
793
+ const topN = topNInput.value ?? 50;
794
+ const pi = DG.TaskBarProgressIndicator.create('Creating heatmap...');
795
+ try {
796
+ const grid = await createExpressionHeatmap(df,
797
+ {topN, title: `Heatmap: Top ${topN} DE Proteins`});
798
+ tv.addViewer(grid);
799
+ } finally {
800
+ pi.close();
801
+ }
802
+ })
803
+ .show();
804
+ }
805
+
806
+ @grok.decorators.func()
807
+ static async showPcaPlot(): Promise<void> {
808
+ const tv = grok.shell.tv;
809
+ const df = tv?.dataFrame;
810
+ if (!tv || !df) { grok.shell.warning('No table open'); return; }
811
+ if (!requireSampleLevelData(df, 'PCA')) return;
812
+ // PCA does NOT require DE -- it works after import/normalization
813
+ const groups = getGroups(df);
814
+ if (!groups) {
815
+ grok.shell.warning('Annotate experimental groups first (Proteomics | Annotate Experiment)');
816
+ return;
817
+ }
818
+ const allCols = [...groups.group1.columns, ...groups.group2.columns];
819
+ const {viewer: sp, pcaDf} = createPcaPlot(df, allCols, groups, 'PCA: All Groups');
820
+ pcaDf.name = `PCA: ${df.name}`;
821
+ // PCA creates its own sample-level DataFrame -- open in separate table view
822
+ const pcaTv = grok.shell.addTableView(pcaDf);
823
+ pcaTv.addViewer(sp);
824
+ }
825
+
826
+ @grok.decorators.func()
827
+ static async showGroupMeanCorrelation(): Promise<void> {
828
+ const tv = grok.shell.tv;
829
+ const df = tv?.dataFrame;
830
+ if (!tv || !df) { grok.shell.warning('No table open'); return; }
831
+ if (!requireDifferentialExpression(df,
832
+ 'Run Differential Expression first (Proteomics | Analyze | Differential Expression)')) return;
833
+ const groups = getGroups(df);
834
+ if (!groups) {
835
+ grok.shell.warning('Annotate experimental groups first (Proteomics | Annotate Experiment)');
836
+ return;
837
+ }
838
+ const sp = createGroupMeanCorrelation(df);
839
+ tv.addViewer(sp);
840
+ }
841
+
842
+ @grok.decorators.func()
843
+ static async showQcDashboard(): Promise<void> {
844
+ const tv = grok.shell.tv;
845
+ const df = tv?.dataFrame;
846
+ if (!tv || !df) { grok.shell.warning('No table open'); return; }
847
+ if (!requireSampleLevelData(df, 'QC Dashboard')) return;
848
+ openQcDashboard(df);
849
+ }
850
+
851
+ @grok.decorators.func()
852
+ static async showSpcDashboard(): Promise<void> {
853
+ await openSpcDashboard();
854
+ }
855
+
856
+ @grok.decorators.func()
857
+ static async showAllVisualizations(): Promise<void> {
858
+ const tv = grok.shell.tv;
859
+ const df = tv?.dataFrame;
860
+ if (!tv || !df) { grok.shell.warning('No table open'); return; }
861
+ if (!requireSampleLevelData(df, 'Show All Visualizations')) return;
862
+ if (!requireDifferentialExpression(df, 'Run Differential Expression first')) return;
863
+ // Volcano -- docked in current table view (protein-level)
864
+ const groups = getGroups(df);
865
+ const volcano = createVolcanoPlot(df);
866
+ tv.addViewer(volcano);
867
+ // Heatmap -- docked in current table view (protein-level)
868
+ const heatmapPi = DG.TaskBarProgressIndicator.create('Creating heatmap...');
869
+ try {
870
+ const heatmap = await createExpressionHeatmap(df, {title: 'Heatmap: Top 50 DE Proteins'});
871
+ tv.addViewer(heatmap);
872
+ } finally {
873
+ heatmapPi.close();
874
+ }
875
+ // PCA (if groups available) -- opens in SEPARATE table view (sample-level, different row count)
876
+ if (groups) {
877
+ const allCols = [...groups.group1.columns, ...groups.group2.columns];
878
+ const {viewer: pcaViewer, pcaDf} = createPcaPlot(df, allCols, groups, 'PCA: All Groups');
879
+ pcaDf.name = `PCA: ${df.name}`;
880
+ const pcaTv = grok.shell.addTableView(pcaDf);
881
+ pcaTv.addViewer(pcaViewer);
882
+ }
883
+ }
884
+
885
+ @grok.decorators.func()
886
+ static async enrichmentAnalysis(): Promise<void> {
887
+ const df = grok.shell.tv?.dataFrame;
888
+ if (!df) { grok.shell.warning('No table open'); return; }
889
+ showEnrichmentDialog(df);
890
+ }
891
+
892
+ @grok.decorators.func()
893
+ static async exportEnrichmentInputs(): Promise<void> {
894
+ const df = grok.shell.tv?.dataFrame;
895
+ if (!df) { grok.shell.warning('No table open'); return; }
896
+ if (!requireDifferentialExpression(df,
897
+ 'Run Differential Expression first (Proteomics | Analyze | Differential Expression)')) return;
898
+ showEnrichmentInputExportDialog(df);
899
+ }
900
+
901
+ @grok.decorators.func()
902
+ static async enrichmentCharts(): Promise<void> {
903
+ const tv = grok.shell.tv;
904
+ const df = tv?.dataFrame;
905
+ if (!df) { grok.shell.warning('No table open'); return; }
906
+ if (df.getTag('proteomics.enrichment') !== 'true') {
907
+ grok.shell.warning('Run Enrichment Analysis first (Proteomics | Enrichment Analysis)');
908
+ return;
909
+ }
910
+ // Find protein DataFrame(s) by proteomics.de_complete tag. If multiple are open,
911
+ // pick the first but warn — the user may have meant a different one.
912
+ const candidates = grok.shell.tables.filter((t: DG.DataFrame) => t.getTag('proteomics.de_complete') === 'true');
913
+ if (candidates.length === 0) {
914
+ grok.shell.warning('No protein table with DE results found. Enrichment charts will open without volcano linking.');
915
+ openEnrichmentVisualization(df, df);
916
+ return;
917
+ }
918
+ if (candidates.length > 1)
919
+ grok.shell.warning(`Multiple protein tables with DE results found; linking enrichment to "${candidates[0].name}"`);
920
+ openEnrichmentVisualization(df, candidates[0]);
921
+ }
922
+
923
+ @grok.decorators.func({
924
+ name: 'Proteomics Demo',
925
+ description: 'End-to-end proteomics differential-expression analysis on the HYE benchmark: ' +
926
+ 'import → impute → differential expression → volcano + heatmap, with a QC dashboard tab ' +
927
+ 'and the top hit pre-selected for its UniProt panel',
928
+ meta: {demoPath: 'Proteomics | Differential Expression', isDemoDashboard: 'true'},
929
+ })
930
+ static async proteomicsDemo(): Promise<void> {
931
+ await runProteomicsDemo();
932
+ }
933
+
934
+ @grok.decorators.func({
935
+ name: 'Proteomics Enrichment Demo',
936
+ description: 'Pathway enrichment (g:Profiler GO / KEGG / Reactome / WikiPathways) on a human ' +
937
+ 'differential-expression result — cell-cycle up, oxidative-phosphorylation down — with the ' +
938
+ 'enrichment charts cross-linked to the volcano',
939
+ meta: {demoPath: 'Proteomics | Enrichment Analysis', isDemoDashboard: 'true'},
940
+ })
941
+ static async proteomicsEnrichmentDemo(): Promise<void> {
942
+ await runEnrichmentDemo();
943
+ }
944
+
945
+ @grok.decorators.panel({
946
+ name: 'Proteomics | UniProt',
947
+ description: 'UniProt protein details',
948
+ meta: {role: 'widgets'},
949
+ })
950
+ static uniprotPanelWidget(
951
+ @grok.decorators.param({options: {semType: 'Proteomics-ProteinId'}}) proteinId: string,
952
+ ): DG.Widget {
953
+ return uniprotPanel(proteinId);
954
+ }
955
+
956
+ @grok.decorators.func()
957
+ static async shareAnalysisForReview(): Promise<void> {
958
+ const tv = grok.shell.tv;
959
+ let df = tv?.dataFrame;
960
+ if (!df) { grok.shell.warning('No table open'); return; }
961
+
962
+ // Share may be invoked from the Enrichment Results tab, whose df is the
963
+ // enrichment frame (no de_complete) rather than the analyzed protein table.
964
+ // Resolve the protein DataFrame: use the current df if it's DE-complete,
965
+ // otherwise find an analyzed table among open tables (mirrors the
966
+ // enrichment-charts handler) so the user doesn't have to switch tabs first.
967
+ if (df.getTag('proteomics.de_complete') !== 'true') {
968
+ const candidates = grok.shell.tables.filter(
969
+ (t: DG.DataFrame) => t.getTag('proteomics.de_complete') === 'true');
970
+ if (candidates.length === 0) {
971
+ requireDifferentialExpression(df,
972
+ 'Run Differential Expression first (Proteomics | Analyze | Differential Expression)');
973
+ return;
974
+ }
975
+ if (candidates.length > 1)
976
+ grok.shell.warning(`Multiple analyzed tables open; sharing "${candidates[0].name}".`);
977
+ df = candidates[0];
978
+ }
979
+ await showShareForReviewDialog(df);
980
+ }
981
+
982
+ @grok.decorators.panel({
983
+ name: 'Proteomics | Shared Analysis',
984
+ description: 'Audit context for a shared analysis snapshot',
985
+ meta: {role: 'widgets'},
986
+ })
987
+ static publishedAnalysisPanelWidget(
988
+ @grok.decorators.param({options: {semType: 'Proteomics-ProteinId'}}) proteinId: string,
989
+ ): DG.Widget {
990
+ return publishedAnalysisPanel(proteinId);
991
+ }
992
+
993
+ @grok.decorators.func({tags: ['autostart'], meta: {autostartImmediate: 'true'}})
994
+ static async recoverPublishedProjectsOnStartup(): Promise<void> {
995
+ const tryEvent: any =
996
+ (grok.events as any).onProjectOpened ??
997
+ (grok.events as any).onCurrentProjectChanged ??
998
+ null;
999
+ const handleOpen = async (): Promise<void> => {
1000
+ await new Promise((r) => setTimeout(r, 200));
1001
+ const tables = (grok.shell as any).tables as DG.DataFrame[] | undefined;
1002
+ if (!Array.isArray(tables)) return;
1003
+ for (const df of tables) {
1004
+ if (isPublished(df)) {
1005
+ try { await recoverPublishedProject(df); } catch (e: any) {
1006
+ grok.shell.warning(`Could not auto-recover shared analysis on open: ${e?.message ?? e}`);
1007
+ }
1008
+ }
1009
+ }
1010
+ };
1011
+ if (tryEvent && typeof tryEvent.subscribe === 'function') {
1012
+ tryEvent.subscribe(() => { void handleOpen(); });
1013
+ } else {
1014
+ const onViewAdded: any = (grok.events as any).onViewAdded;
1015
+ if (onViewAdded && typeof onViewAdded.subscribe === 'function') {
1016
+ onViewAdded.subscribe((view: DG.View) => {
1017
+ if ((view as any) instanceof DG.TableView) {
1018
+ const df = (view as any).dataFrame;
1019
+ if (df && isPublished(df)) {
1020
+ recoverPublishedProject(df).catch((e: any) => {
1021
+ grok.shell.warning(`Could not auto-recover shared analysis on open: ${e?.message ?? e}`);
1022
+ });
1023
+ }
1024
+ }
1025
+ });
1026
+ }
1027
+ }
1028
+ }
1029
+ }