@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.
- package/CHANGELOG.md +52 -3
- package/CLAUDE.md +178 -0
- package/README.md +195 -3
- package/detectors.js +152 -9
- package/dist/package-test.js +1 -1744
- package/dist/package-test.js.map +1 -1
- package/dist/package.js +1 -146
- package/dist/package.js.map +1 -1
- package/docs/personas-and-capabilities.md +165 -0
- package/files/demo/README.md +264 -0
- package/files/demo/cptac-spike-in.txt +1571 -0
- package/files/demo/enrichment-demo.csv +120 -0
- package/files/demo/fragpipe-smoke-test.tsv +11 -0
- package/files/demo/proteinGroups.txt +28 -0
- package/files/demo/spectronaut-hye-candidates.tsv +94 -0
- package/files/demo/spectronaut-hye-demo.tsv +8761 -0
- package/files/demo/spectronaut-hye-mix.tsv +8761 -0
- package/files/demo/spectronaut-hye-precursor-golden.json +938 -0
- package/files/demo/spectronaut-hye-precursor-golden.tsv +235 -0
- package/files/demo/spectronaut-hye-precursor.tsv +493 -0
- package/images/enrichment-crosslink.png +0 -0
- package/images/enrichment-term-selected.png +0 -0
- package/images/hero.png +0 -0
- package/images/pipeline.svg +80 -0
- package/package.json +87 -63
- package/scripts/deqms_de.R +60 -0
- package/scripts/limma_de.R +42 -0
- package/scripts/vsn_normalize.R +19 -0
- package/src/analysis/differential-expression.ts +450 -0
- package/src/analysis/enrichment-export.ts +101 -0
- package/src/analysis/enrichment.ts +602 -0
- package/src/analysis/experiment-setup.ts +199 -0
- package/src/analysis/imputation.ts +407 -0
- package/src/analysis/log2-scale.ts +139 -0
- package/src/analysis/normalization.ts +255 -0
- package/src/analysis/pca.ts +254 -0
- package/src/analysis/spc-storage.ts +515 -0
- package/src/analysis/spc.ts +544 -0
- package/src/analysis/subcellular-location.ts +431 -0
- package/src/demo/enrichment-demo.ts +94 -0
- package/src/demo/proteomics-demo.ts +123 -0
- package/src/global.d.ts +15 -0
- package/src/menu.ts +133 -0
- package/src/package-api.ts +136 -14
- package/src/package-test.ts +45 -20
- package/src/package.g.ts +161 -0
- package/src/package.ts +1029 -17
- package/src/panels/protein-focus.ts +63 -0
- package/src/panels/published-analysis-panel.ts +151 -0
- package/src/panels/uniprot-panel.ts +349 -0
- package/src/parsers/fragpipe-parser.ts +200 -0
- package/src/parsers/generic-parser.ts +197 -0
- package/src/parsers/maxquant-parser.ts +162 -0
- package/src/parsers/shared-utils.ts +163 -0
- package/src/parsers/spectronaut-candidates-parser.ts +307 -0
- package/src/parsers/spectronaut-parser.ts +604 -0
- package/src/publishing/assert-published-shape.ts +260 -0
- package/src/publishing/post-open-recovery.ts +104 -0
- package/src/publishing/publish-project.ts +515 -0
- package/src/publishing/publish-settings.ts +59 -0
- package/src/publishing/publish-state.ts +316 -0
- package/src/publishing/share-dialog.ts +171 -0
- package/src/publishing/trim-dataframe.ts +247 -0
- package/src/tests/analysis.ts +658 -0
- package/src/tests/enrichment-export.ts +61 -0
- package/src/tests/enrichment-visualization.ts +340 -0
- package/src/tests/enrichment.ts +224 -0
- package/src/tests/fragpipe-e2e.ts +74 -0
- package/src/tests/fragpipe-parser.ts +147 -0
- package/src/tests/gene-label-resolver.ts +387 -0
- package/src/tests/generic-parser.ts +152 -0
- package/src/tests/group-mean-correlation.ts +139 -0
- package/src/tests/log2-scale.ts +93 -0
- package/src/tests/organisms.ts +56 -0
- package/src/tests/parsers.ts +182 -0
- package/src/tests/publish-roundtrip.ts +584 -0
- package/src/tests/publish-spike.ts +377 -0
- package/src/tests/qc-dashboard.ts +210 -0
- package/src/tests/smart-pathway-filter.ts +193 -0
- package/src/tests/spc-formula-lines-spike.ts +129 -0
- package/src/tests/spc.ts +640 -0
- package/src/tests/spectronaut-candidates-e2e.ts +140 -0
- package/src/tests/spectronaut-candidates-parser.ts +398 -0
- package/src/tests/spectronaut-parser.ts +668 -0
- package/src/tests/subcellular-location.ts +361 -0
- package/src/tests/uniprot-panel.ts +202 -0
- package/src/tests/volcano.ts +603 -0
- package/src/utils/column-detection.ts +28 -0
- package/src/utils/gene-label-resolver.ts +443 -0
- package/src/utils/organisms.ts +82 -0
- package/src/utils/proteomics-types.ts +30 -0
- package/src/viewers/enrichment-viewers.ts +274 -0
- package/src/viewers/group-mean-correlation.ts +218 -0
- package/src/viewers/heatmap.ts +168 -0
- package/src/viewers/pca-plot.ts +169 -0
- package/src/viewers/qc-computations.ts +266 -0
- package/src/viewers/qc-dashboard.ts +176 -0
- package/src/viewers/spc-dashboard.ts +755 -0
- package/src/viewers/volcano.ts +690 -0
- package/test-console-output-1.log +2055 -0
- package/test-record-1.mp4 +0 -0
- package/tools/derive-precursor-golden-sidecar.mjs +81 -0
- package/tools/generate-enrichment-fixture.sh +160 -0
- package/tools/generate-spectronaut-candidates-fixture.mjs +212 -0
- package/tools/generate-spectronaut-precursor-fixture.mjs +128 -0
- package/tools/spectronaut-aggregate.sh +46 -0
- package/tools/spectronaut-aggregate.sql +80 -0
- package/tsconfig.json +18 -71
- package/webpack.config.js +86 -45
- package/.eslintignore +0 -1
- package/.eslintrc.json +0 -56
- package/LICENSE +0 -674
- package/package.png +0 -0
- package/scripts/number_antibody.py +0 -190
- package/scripts/number_antibody_abnumber.py +0 -177
- package/scripts/number_antibody_anarci.py +0 -200
package/src/package.ts
CHANGED
|
@@ -1,17 +1,1029 @@
|
|
|
1
|
-
/*
|
|
2
|
-
|
|
3
|
-
import * as
|
|
4
|
-
import * as
|
|
5
|
-
import * as
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
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
|
+
}
|