@datagrok/proteomics 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +52 -3
  2. package/CLAUDE.md +178 -0
  3. package/README.md +195 -3
  4. package/detectors.js +152 -9
  5. package/dist/package-test.js +1 -1744
  6. package/dist/package-test.js.map +1 -1
  7. package/dist/package.js +1 -146
  8. package/dist/package.js.map +1 -1
  9. package/docs/personas-and-capabilities.md +165 -0
  10. package/files/demo/README.md +264 -0
  11. package/files/demo/cptac-spike-in.txt +1571 -0
  12. package/files/demo/enrichment-demo.csv +120 -0
  13. package/files/demo/fragpipe-smoke-test.tsv +11 -0
  14. package/files/demo/proteinGroups.txt +28 -0
  15. package/files/demo/spectronaut-hye-candidates.tsv +94 -0
  16. package/files/demo/spectronaut-hye-demo.tsv +8761 -0
  17. package/files/demo/spectronaut-hye-mix.tsv +8761 -0
  18. package/files/demo/spectronaut-hye-precursor-golden.json +938 -0
  19. package/files/demo/spectronaut-hye-precursor-golden.tsv +235 -0
  20. package/files/demo/spectronaut-hye-precursor.tsv +493 -0
  21. package/images/enrichment-crosslink.png +0 -0
  22. package/images/enrichment-term-selected.png +0 -0
  23. package/images/hero.png +0 -0
  24. package/images/pipeline.svg +80 -0
  25. package/package.json +87 -62
  26. package/scripts/deqms_de.R +60 -0
  27. package/scripts/limma_de.R +42 -0
  28. package/scripts/vsn_normalize.R +19 -0
  29. package/src/analysis/differential-expression.ts +450 -0
  30. package/src/analysis/enrichment-export.ts +101 -0
  31. package/src/analysis/enrichment.ts +602 -0
  32. package/src/analysis/experiment-setup.ts +199 -0
  33. package/src/analysis/imputation.ts +407 -0
  34. package/src/analysis/log2-scale.ts +139 -0
  35. package/src/analysis/normalization.ts +255 -0
  36. package/src/analysis/pca.ts +254 -0
  37. package/src/analysis/spc-storage.ts +515 -0
  38. package/src/analysis/spc.ts +544 -0
  39. package/src/analysis/subcellular-location.ts +431 -0
  40. package/src/demo/enrichment-demo.ts +94 -0
  41. package/src/demo/proteomics-demo.ts +123 -0
  42. package/src/global.d.ts +15 -0
  43. package/src/menu.ts +133 -0
  44. package/src/package-api.ts +136 -14
  45. package/src/package-test.ts +45 -20
  46. package/src/package.g.ts +161 -0
  47. package/src/package.ts +1029 -17
  48. package/src/panels/protein-focus.ts +63 -0
  49. package/src/panels/published-analysis-panel.ts +151 -0
  50. package/src/panels/uniprot-panel.ts +349 -0
  51. package/src/parsers/fragpipe-parser.ts +200 -0
  52. package/src/parsers/generic-parser.ts +197 -0
  53. package/src/parsers/maxquant-parser.ts +162 -0
  54. package/src/parsers/shared-utils.ts +163 -0
  55. package/src/parsers/spectronaut-candidates-parser.ts +307 -0
  56. package/src/parsers/spectronaut-parser.ts +604 -0
  57. package/src/publishing/assert-published-shape.ts +260 -0
  58. package/src/publishing/post-open-recovery.ts +104 -0
  59. package/src/publishing/publish-project.ts +515 -0
  60. package/src/publishing/publish-settings.ts +59 -0
  61. package/src/publishing/publish-state.ts +316 -0
  62. package/src/publishing/share-dialog.ts +171 -0
  63. package/src/publishing/trim-dataframe.ts +247 -0
  64. package/src/tests/analysis.ts +658 -0
  65. package/src/tests/enrichment-export.ts +61 -0
  66. package/src/tests/enrichment-visualization.ts +340 -0
  67. package/src/tests/enrichment.ts +224 -0
  68. package/src/tests/fragpipe-e2e.ts +74 -0
  69. package/src/tests/fragpipe-parser.ts +147 -0
  70. package/src/tests/gene-label-resolver.ts +387 -0
  71. package/src/tests/generic-parser.ts +152 -0
  72. package/src/tests/group-mean-correlation.ts +139 -0
  73. package/src/tests/log2-scale.ts +93 -0
  74. package/src/tests/organisms.ts +56 -0
  75. package/src/tests/parsers.ts +182 -0
  76. package/src/tests/publish-roundtrip.ts +584 -0
  77. package/src/tests/publish-spike.ts +377 -0
  78. package/src/tests/qc-dashboard.ts +210 -0
  79. package/src/tests/smart-pathway-filter.ts +193 -0
  80. package/src/tests/spc-formula-lines-spike.ts +129 -0
  81. package/src/tests/spc.ts +640 -0
  82. package/src/tests/spectronaut-candidates-e2e.ts +140 -0
  83. package/src/tests/spectronaut-candidates-parser.ts +398 -0
  84. package/src/tests/spectronaut-parser.ts +668 -0
  85. package/src/tests/subcellular-location.ts +361 -0
  86. package/src/tests/uniprot-panel.ts +202 -0
  87. package/src/tests/volcano.ts +603 -0
  88. package/src/utils/column-detection.ts +28 -0
  89. package/src/utils/gene-label-resolver.ts +443 -0
  90. package/src/utils/organisms.ts +82 -0
  91. package/src/utils/proteomics-types.ts +30 -0
  92. package/src/viewers/enrichment-viewers.ts +274 -0
  93. package/src/viewers/group-mean-correlation.ts +218 -0
  94. package/src/viewers/heatmap.ts +168 -0
  95. package/src/viewers/pca-plot.ts +169 -0
  96. package/src/viewers/qc-computations.ts +266 -0
  97. package/src/viewers/qc-dashboard.ts +176 -0
  98. package/src/viewers/spc-dashboard.ts +755 -0
  99. package/src/viewers/volcano.ts +690 -0
  100. package/test-console-output-1.log +2055 -0
  101. package/test-record-1.mp4 +0 -0
  102. package/tools/derive-precursor-golden-sidecar.mjs +81 -0
  103. package/tools/generate-enrichment-fixture.sh +160 -0
  104. package/tools/generate-spectronaut-candidates-fixture.mjs +212 -0
  105. package/tools/generate-spectronaut-precursor-fixture.mjs +128 -0
  106. package/tools/spectronaut-aggregate.sh +46 -0
  107. package/tools/spectronaut-aggregate.sql +80 -0
  108. package/tsconfig.json +18 -71
  109. package/webpack.config.js +86 -45
  110. package/.eslintignore +0 -1
  111. package/.eslintrc.json +0 -56
  112. package/LICENSE +0 -674
  113. package/package.png +0 -0
  114. package/scripts/number_antibody.py +0 -190
  115. package/scripts/number_antibody_abnumber.py +0 -177
  116. package/scripts/number_antibody_anarci.py +0 -200
@@ -0,0 +1,200 @@
1
+ import * as DG from 'datagrok-api/dg';
2
+ import {SEMTYPE} from '../utils/proteomics-types';
3
+ import {log2TransformColumns, addPrimaryColumnIfNeeded} from './shared-utils';
4
+ import {resolveGeneLabels} from '../utils/gene-label-resolver';
5
+
6
+ /** Per-sample intensity column suffixes in FragPipe's combined_protein.tsv,
7
+ * in order of quantitative preference. MaxLFQ is the FragPipe-recommended
8
+ * LFQ quant; bare " Intensity" is the summed peptide intensity. Razor is
9
+ * the razor-protein attribution and not normally used as the primary quant. */
10
+ const INTENSITY_SUFFIXES = [' MaxLFQ Intensity', ' Intensity', ' Razor Intensity'] as const;
11
+
12
+ /** Column name variants for protein-level identifiers in FragPipe output.
13
+ * `Protein ID` is the UniProt accession; `Protein` is the full FASTA header
14
+ * (e.g., `sp|P12345|GENE_HUMAN`) which we prefer only if `Protein ID` is missing. */
15
+ const PROTEIN_ID_COLUMNS = ['Protein ID', 'Protein'];
16
+
17
+ /** Column name variants for gene symbols. */
18
+ const GENE_NAME_COLUMNS = ['Gene', 'Gene Names', 'Gene Symbol'];
19
+
20
+ /** Decoy / contaminant prefixes to filter out at the row level.
21
+ * `contam_` is FragPipe/Philosopher's convention; `rev_` is Philosopher's decoy
22
+ * prefix (usually filtered upstream, but defensive); CON__/REV__ cover the
23
+ * case where the FASTA was MaxQuant-formatted. */
24
+ const CONTAMINANT_PREFIXES = ['contam_', 'rev_', 'CON__', 'REV__'];
25
+
26
+ /** Finds the first existing column from a list of name candidates. */
27
+ function findCol(df: DG.DataFrame, names: readonly string[]): DG.Column | null {
28
+ for (const name of names) {
29
+ const col = df.col(name);
30
+ if (col)
31
+ return col;
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /** Checks whether a header is a per-sample intensity column.
37
+ * Returns the matched suffix (lowercased) or null. */
38
+ function matchIntensitySuffix(header: string): string | null {
39
+ const lower = header.toLowerCase();
40
+ for (const suffix of INTENSITY_SUFFIXES) {
41
+ if (lower.endsWith(suffix.toLowerCase()) && lower.length > suffix.length)
42
+ return suffix.toLowerCase();
43
+ }
44
+ return null;
45
+ }
46
+
47
+ /** Marks rows for exclusion when their primary protein identifier starts with
48
+ * any contaminant/decoy prefix. Checks both `Protein ID` and `Protein` because
49
+ * Philosopher contam_/rev_ prefixes appear on whichever column is populated. */
50
+ function filterContaminantRows(df: DG.DataFrame): void {
51
+ const candidates = ['Protein ID', 'Protein'];
52
+ const cols: DG.Column[] = [];
53
+ for (const name of candidates) {
54
+ const c = df.col(name);
55
+ if (c) cols.push(c);
56
+ }
57
+ if (cols.length === 0) return;
58
+
59
+ for (let i = 0; i < df.rowCount; i++) {
60
+ for (const col of cols) {
61
+ const val = col.get(i);
62
+ if (typeof val !== 'string') continue;
63
+ let matched = false;
64
+ for (const prefix of CONTAMINANT_PREFIXES) {
65
+ if (val.startsWith(prefix)) {
66
+ matched = true;
67
+ break;
68
+ }
69
+ }
70
+ if (matched) {
71
+ df.filter.set(i, false, false);
72
+ break;
73
+ }
74
+ }
75
+ }
76
+ }
77
+
78
+ /** Scans the header row to find intensity columns and forces them to `double`
79
+ * during CSV import. Without this, fromCsv can mistype large-integer intensities
80
+ * (or all-zero / all-blank samples) as boolean or int. Mirrors the same guard
81
+ * used by the MaxQuant parser. */
82
+ function buildIntensityColumnOptions(text: string): DG.CsvImportColumnOptions[] {
83
+ const newlineIdx = text.indexOf('\n');
84
+ const firstLine = (newlineIdx >= 0 ? text.substring(0, newlineIdx) : text).replace(/\r$/, '');
85
+ const headers = firstLine.split('\t');
86
+ const opts: DG.CsvImportColumnOptions[] = [];
87
+ for (const header of headers) {
88
+ if (matchIntensitySuffix(header))
89
+ opts.push({name: header, type: 'double'});
90
+ }
91
+ return opts;
92
+ }
93
+
94
+ /** Returns the set of intensity columns to log2-transform. If MaxLFQ Intensity
95
+ * columns exist, prefer them and drop the redundant bare " Intensity" / " Razor
96
+ * Intensity" columns for the same sample. If MaxLFQ is absent (e.g. spectral-
97
+ * count-only run, or LFQ not enabled), fall back to " Intensity". Razor is only
98
+ * used as a last resort. Returned columns still get SEMTYPE.INTENSITY below. */
99
+ function pickQuantColumns(df: DG.DataFrame): string[] {
100
+ type Bucket = {maxLfq?: string; intensity?: string; razor?: string};
101
+ const samples = new Map<string, Bucket>();
102
+
103
+ for (const name of df.columns.names()) {
104
+ const col = df.col(name);
105
+ if (!col) continue;
106
+ if (col.type !== DG.COLUMN_TYPE.FLOAT && col.type !== DG.COLUMN_TYPE.INT &&
107
+ col.type !== DG.COLUMN_TYPE.BIG_INT)
108
+ continue;
109
+ const lower = name.toLowerCase();
110
+ let sample: string | null = null;
111
+ let kind: keyof Bucket | null = null;
112
+ if (lower.endsWith(' maxlfq intensity')) {
113
+ sample = name.substring(0, name.length - ' MaxLFQ Intensity'.length);
114
+ kind = 'maxLfq';
115
+ } else if (lower.endsWith(' razor intensity')) {
116
+ sample = name.substring(0, name.length - ' Razor Intensity'.length);
117
+ kind = 'razor';
118
+ } else if (lower.endsWith(' intensity')) {
119
+ sample = name.substring(0, name.length - ' Intensity'.length);
120
+ kind = 'intensity';
121
+ }
122
+ if (!sample || !kind) continue;
123
+ if (!samples.has(sample)) samples.set(sample, {});
124
+ samples.get(sample)![kind] = name;
125
+ }
126
+
127
+ const chosen: string[] = [];
128
+ for (const bucket of samples.values()) {
129
+ if (bucket.maxLfq) chosen.push(bucket.maxLfq);
130
+ else if (bucket.intensity) chosen.push(bucket.intensity);
131
+ else if (bucket.razor) chosen.push(bucket.razor);
132
+ }
133
+ return chosen;
134
+ }
135
+
136
+ /** Assigns semantic types to ID, gene, and the chosen per-sample intensity columns.
137
+ * Only `chosenQuant` columns receive SEMTYPE.INTENSITY — the redundant Razor /
138
+ * bare-Intensity columns we dropped from log2 transform must not surface as
139
+ * intensities in downstream pickers. */
140
+ function assignSemanticTypes(df: DG.DataFrame, chosenQuant: string[]): void {
141
+ const proteinCol = findCol(df, PROTEIN_ID_COLUMNS);
142
+ if (proteinCol)
143
+ proteinCol.semType = SEMTYPE.PROTEIN_ID;
144
+
145
+ const geneCol = findCol(df, GENE_NAME_COLUMNS);
146
+ if (geneCol)
147
+ geneCol.semType = SEMTYPE.GENE_SYMBOL;
148
+
149
+ for (const name of chosenQuant) {
150
+ const col = df.col(name);
151
+ if (col) col.semType = SEMTYPE.INTENSITY;
152
+ }
153
+ }
154
+
155
+ /** Finds the source column for primary extraction from FragPipe-specific name
156
+ * variants and delegates to shared addPrimaryColumnIfNeeded. */
157
+ function addPrimaryColumnFromVariants(df: DG.DataFrame, sourceNames: readonly string[],
158
+ newName: string, semType: string): void {
159
+ const srcCol = findCol(df, sourceNames);
160
+ if (!srcCol) return;
161
+ addPrimaryColumnIfNeeded(df, srcCol.name, newName, semType);
162
+ }
163
+
164
+ /** Parses a FragPipe `combined_protein.tsv` into a filtered, typed DataFrame.
165
+ *
166
+ * Applies:
167
+ * - Contaminant/decoy row filtering (`contam_`, `rev_`, plus MaxQuant-style
168
+ * CON__/REV__ as a fallback)
169
+ * - Semantic type assignment (PROTEIN_ID, GENE_SYMBOL, INTENSITY)
170
+ * - Primary protein/gene split for semicolon-delimited identifier lists
171
+ * - Log2 transform of the preferred quantitative intensity columns
172
+ * (MaxLFQ Intensity > Intensity > Razor Intensity, picked per sample)
173
+ *
174
+ * The returned DataFrame is ready for the standard pipeline: Annotate
175
+ * Experiment -> Normalize -> Impute -> Differential Expression -> Viewers. */
176
+ export async function parseFragPipeText(text: string): Promise<DG.DataFrame> {
177
+ const columnImportOptions = buildIntensityColumnOptions(text);
178
+ const raw = DG.DataFrame.fromCsv(text, {delimiter: '\t', columnImportOptions});
179
+
180
+ raw.filter.init((_i) => true);
181
+ filterContaminantRows(raw);
182
+ raw.filter.fireChanged();
183
+
184
+ const df = raw.clone(raw.filter);
185
+
186
+ // Choose one quant column per sample for log2 transform.
187
+ const chosen = pickQuantColumns(df);
188
+ assignSemanticTypes(df, chosen);
189
+
190
+ addPrimaryColumnFromVariants(df, PROTEIN_ID_COLUMNS, 'Primary Protein ID', SEMTYPE.PROTEIN_ID);
191
+ addPrimaryColumnFromVariants(df, GENE_NAME_COLUMNS, 'Primary Gene Name', SEMTYPE.GENE_SYMBOL);
192
+
193
+ log2TransformColumns(df, chosen);
194
+
195
+ df.setTag('proteomics.source', 'fragpipe');
196
+
197
+ await resolveGeneLabels(df);
198
+
199
+ return df;
200
+ }
@@ -0,0 +1,197 @@
1
+ import * as grok from 'datagrok-api/grok';
2
+ import * as ui from 'datagrok-api/ui';
3
+ import * as DG from 'datagrok-api/dg';
4
+
5
+ import {SEMTYPE} from '../utils/proteomics-types';
6
+ import {focusProtein} from '../panels/protein-focus';
7
+ import {
8
+ log2TransformColumns,
9
+ copyAsLog2Columns,
10
+ addPrimaryColumnIfNeeded,
11
+ detectLog2Status,
12
+ detectDelimiter,
13
+ autoSuggestProteinIdColumn,
14
+ autoSuggestIntensityColumns,
15
+ autoSuggestGeneNameColumn,
16
+ } from './shared-utils';
17
+ import {resolveGeneLabels} from '../utils/gene-label-resolver';
18
+
19
+ /** Opens a file picker and shows the column mapping dialog for generic matrix import. */
20
+ export function showGenericImportDialog(): void {
21
+ DG.Utils.openFile({
22
+ accept: '.csv,.tsv,.txt',
23
+ open: (file: File) => {
24
+ const reader = new FileReader();
25
+ reader.onload = () => {
26
+ try {
27
+ const text = reader.result as string;
28
+ const delimiter = detectDelimiter(text);
29
+ const df = DG.DataFrame.fromCsv(text, {delimiter});
30
+ showMappingDialog(df, file.name);
31
+ } catch (e: any) {
32
+ grok.shell.error('Failed to parse file: ' + e.message);
33
+ }
34
+ };
35
+ reader.readAsText(file);
36
+ },
37
+ });
38
+ }
39
+
40
+ /** Shows the column mapping dialog for a parsed DataFrame.
41
+ * @param df - Parsed DataFrame from the imported file
42
+ * @param fileName - Original file name for naming the output DataFrame */
43
+ function showMappingDialog(df: DG.DataFrame, fileName: string): void {
44
+ // Auto-suggest columns
45
+ const suggestedProtein = autoSuggestProteinIdColumn(df);
46
+ const suggestedGene = autoSuggestGeneNameColumn(df);
47
+ const suggestedIntensityNames = autoSuggestIntensityColumns(df);
48
+
49
+ // Protein ID input -- string columns only
50
+ const proteinIdInput = ui.input.column('Protein ID', {
51
+ table: df,
52
+ filter: (c: DG.Column) => c.type === DG.COLUMN_TYPE.STRING,
53
+ value: suggestedProtein ?? undefined,
54
+ });
55
+
56
+ // Gene Name input -- optional, string columns only
57
+ const geneNameInput = ui.input.column('Gene Name (optional)', {
58
+ table: df,
59
+ filter: (c: DG.Column) => c.type === DG.COLUMN_TYPE.STRING,
60
+ value: suggestedGene ?? undefined,
61
+ nullable: true,
62
+ });
63
+
64
+ // Intensity columns multi-select — show all numeric columns, pre-select keyword matches
65
+ const numericColNames = df.columns.toList()
66
+ .filter((c) => c.type === DG.COLUMN_TYPE.FLOAT || c.type === DG.COLUMN_TYPE.INT || c.type === DG.COLUMN_TYPE.BIG_INT)
67
+ .map((c) => c.name);
68
+ const intensityColsInput = ui.input.columns('Intensity Columns', {
69
+ table: df,
70
+ available: numericColNames,
71
+ value: suggestedIntensityNames.map((n) => df.col(n)!).filter((c) => c != null),
72
+ });
73
+
74
+ // Log2 detection on initial intensity columns
75
+ const initialDetection = detectLog2Status(df, suggestedIntensityNames);
76
+
77
+ // Log2 toggle -- ON means "needs transform" (raw intensities)
78
+ const log2Toggle = ui.input.bool('Log2 Transform', {value: !initialDetection.isLog2});
79
+
80
+ // Hint label for log2 detection message
81
+ const hintDiv = ui.divText(initialDetection.message);
82
+ hintDiv.style.cssText = 'font-style:italic;color:#888;margin-bottom:8px;font-size:12px;';
83
+
84
+ // Preview container
85
+ const previewContainer = document.createElement('div');
86
+ previewContainer.style.cssText = 'border:1px solid #ddd;margin-top:8px;width:100%;';
87
+
88
+ /** Updates the preview grid with currently selected columns. */
89
+ function updatePreview(): void {
90
+ const selectedNames: string[] = [];
91
+ const proteinCol = proteinIdInput.value;
92
+ if (proteinCol) selectedNames.push(proteinCol.name);
93
+ const geneCol = geneNameInput.value;
94
+ if (geneCol) selectedNames.push(geneCol.name);
95
+ const intensityCols: DG.Column[] = intensityColsInput.value ?? [];
96
+ const intensityNames = intensityCols.map((c) => c.name);
97
+ selectedNames.push(...intensityNames);
98
+
99
+ if (selectedNames.length === 0) {
100
+ previewContainer.innerHTML = '<div style="padding:8px;color:#888">Select columns to preview</div>';
101
+ return;
102
+ }
103
+
104
+ try {
105
+ const mask = DG.BitSet.create(df.rowCount, (i) => i < 5);
106
+ const previewDf = df.clone(mask, selectedNames);
107
+ const grid = DG.Viewer.grid(previewDf);
108
+ previewContainer.innerHTML = '';
109
+ const countLabel = ui.divText(`Preview: ${selectedNames.length} columns, 5 rows`);
110
+ countLabel.style.cssText = 'font-size:11px;color:#888;padding:2px 4px;';
111
+ grid.root.style.cssText = 'width:100%;height:180px;';
112
+ previewContainer.appendChild(countLabel);
113
+ previewContainer.appendChild(grid.root);
114
+ } catch {
115
+ previewContainer.innerHTML = '<div style="padding:8px;color:#888">Unable to generate preview</div>';
116
+ }
117
+ }
118
+
119
+ /** Updates log2 detection hint based on current intensity columns. */
120
+ function updateLog2Detection(): void {
121
+ const intensityCols: DG.Column[] = intensityColsInput.value ?? [];
122
+ const names = intensityCols.map((c) => c.name);
123
+ if (names.length === 0) {
124
+ hintDiv.textContent = 'Select intensity columns';
125
+ return;
126
+ }
127
+ const detection = detectLog2Status(df, names);
128
+ hintDiv.textContent = detection.message;
129
+ log2Toggle.value = !detection.isLog2;
130
+ }
131
+
132
+ // Wire up reactive updates
133
+ proteinIdInput.onChanged.subscribe(() => updatePreview());
134
+ geneNameInput.onChanged.subscribe(() => updatePreview());
135
+ intensityColsInput.onChanged.subscribe(() => {
136
+ updateLog2Detection();
137
+ updatePreview();
138
+ });
139
+
140
+ // Initial preview
141
+ updatePreview();
142
+
143
+ // Build dialog
144
+ ui.dialog('Import Generic Matrix')
145
+ .add(proteinIdInput)
146
+ .add(geneNameInput)
147
+ .add(intensityColsInput)
148
+ .add(log2Toggle)
149
+ .add(ui.div([hintDiv]))
150
+ .add(previewContainer)
151
+ .onOK(async () => {
152
+ const proteinCol = proteinIdInput.value;
153
+ if (!proteinCol) {
154
+ grok.shell.warning('Please select a Protein ID column');
155
+ return;
156
+ }
157
+
158
+ const intensityCols: DG.Column[] = intensityColsInput.value ?? [];
159
+ const intensityNames = intensityCols.map((c) => c.name);
160
+ if (intensityNames.length === 0) {
161
+ grok.shell.warning('Please select at least one intensity column');
162
+ return;
163
+ }
164
+
165
+ // Assign semantic types
166
+ proteinCol.semType = SEMTYPE.PROTEIN_ID;
167
+
168
+ const geneCol = geneNameInput.value;
169
+ if (geneCol)
170
+ geneCol.semType = SEMTYPE.GENE_SYMBOL;
171
+
172
+ // Log2 transform or copy
173
+ if (log2Toggle.value)
174
+ log2TransformColumns(df, intensityNames);
175
+ else
176
+ copyAsLog2Columns(df, intensityNames);
177
+
178
+ // Primary columns (splits semicolon-delimited values)
179
+ addPrimaryColumnIfNeeded(df, proteinCol.name, 'Primary Protein ID', SEMTYPE.PROTEIN_ID);
180
+ if (geneCol)
181
+ addPrimaryColumnIfNeeded(df, geneCol.name, 'Primary Gene Name', SEMTYPE.GENE_SYMBOL);
182
+
183
+ // Source tag
184
+ df.setTag('proteomics.source', 'generic');
185
+
186
+ await resolveGeneLabels(df);
187
+
188
+ // Name from file
189
+ df.name = fileName.replace(/\.[^.]+$/, '');
190
+
191
+ // Open in table view
192
+ grok.shell.addTableView(df);
193
+ grok.shell.info(`Imported ${df.rowCount} proteins from ${fileName}`);
194
+ focusProtein(df);
195
+ })
196
+ .show();
197
+ }
@@ -0,0 +1,162 @@
1
+ import * as DG from 'datagrok-api/dg';
2
+ import {SEMTYPE} from '../utils/proteomics-types';
3
+ import {log2TransformColumns, addPrimaryColumnIfNeeded} from './shared-utils';
4
+ import {resolveGeneLabels} from '../utils/gene-label-resolver';
5
+
6
+ /** Intensity column prefixes to detect, checked in this order.
7
+ * "intensity" must be last since it is a substring of "lfq intensity". */
8
+ const INTENSITY_PREFIXES = ['lfq intensity', 'ibaq', 'reporter intensity', 'intensity'];
9
+
10
+ /** Column name variants for filter columns across MaxQuant versions. */
11
+ const FILTER_COLUMNS = {
12
+ contaminant: ['Potential contaminant', 'Potential.contaminant'],
13
+ reverse: ['Reverse'],
14
+ onlyBySite: ['Only identified by site', 'Only.identified.by.site'],
15
+ } as const;
16
+
17
+ /** Column name variants for protein IDs. */
18
+ const PROTEIN_ID_COLUMNS = ['Protein IDs', 'Majority protein IDs'];
19
+
20
+ /** Column name variants for gene names. */
21
+ const GENE_NAME_COLUMNS = ['Gene names', 'Gene name'];
22
+
23
+ /** Finds the first existing column from a list of name candidates. */
24
+ function findCol(df: DG.DataFrame, names: readonly string[]): DG.Column | null {
25
+ for (const name of names) {
26
+ const col = df.col(name);
27
+ if (col)
28
+ return col;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ /** Marks rows for exclusion where the given column has value "+". */
34
+ function filterByMarker(df: DG.DataFrame, colNames: readonly string[]): void {
35
+ const col = findCol(df, colNames);
36
+ if (!col) return;
37
+ for (let i = 0; i < df.rowCount; i++) {
38
+ if (col.get(i) === '+')
39
+ df.filter.set(i, false, false);
40
+ }
41
+ }
42
+
43
+ /** Marks rows for exclusion when any protein-id column starts with CON__/REV__.
44
+ * Scans every column in PROTEIN_ID_COLUMNS rather than the first match — MaxQuant
45
+ * sometimes drops the prefix from `Protein IDs` but retains it in `Majority protein IDs`. */
46
+ function filterByIdPrefix(df: DG.DataFrame): void {
47
+ const cols: DG.Column[] = [];
48
+ for (const name of PROTEIN_ID_COLUMNS) {
49
+ const c = df.col(name);
50
+ if (c) cols.push(c);
51
+ }
52
+ if (cols.length === 0) return;
53
+ for (let i = 0; i < df.rowCount; i++) {
54
+ for (const col of cols) {
55
+ const val = col.get(i);
56
+ if (typeof val === 'string' && (val.startsWith('CON__') || val.startsWith('REV__'))) {
57
+ df.filter.set(i, false, false);
58
+ break;
59
+ }
60
+ }
61
+ }
62
+ }
63
+
64
+ /** Finds the source column for primary extraction from MaxQuant-specific name variants,
65
+ * then delegates to shared addPrimaryColumnIfNeeded which handles semicolon detection. */
66
+ function addPrimaryColumnFromVariants(df: DG.DataFrame, sourceNames: readonly string[],
67
+ newName: string, semType: string): void {
68
+ const srcCol = findCol(df, sourceNames);
69
+ if (!srcCol) return;
70
+ addPrimaryColumnIfNeeded(df, srcCol.name, newName, semType);
71
+ }
72
+
73
+ /** Scans header row to find intensity column names and build columnImportOptions
74
+ * that force them to double. Prevents fromCsv from mistyping large-integer
75
+ * intensity columns as boolean. */
76
+ function buildIntensityColumnOptions(text: string): DG.CsvImportColumnOptions[] {
77
+ const newlineIdx = text.indexOf('\n');
78
+ const firstLine = (newlineIdx >= 0 ? text.substring(0, newlineIdx) : text).replace(/\r$/, '');
79
+ const headers = firstLine.split('\t');
80
+ const opts: DG.CsvImportColumnOptions[] = [];
81
+ for (const header of headers) {
82
+ const lower = header.toLowerCase();
83
+ for (const prefix of INTENSITY_PREFIXES) {
84
+ if (lower.startsWith(prefix)) {
85
+ opts.push({name: header, type: 'double'});
86
+ break;
87
+ }
88
+ }
89
+ }
90
+ return opts;
91
+ }
92
+
93
+ /** Detects intensity columns by MaxQuant-specific prefix matching and
94
+ * delegates to shared log2TransformColumns for the actual transformation. */
95
+ function processIntensityColumns(df: DG.DataFrame): void {
96
+ const intensityNames: string[] = [];
97
+ for (const name of df.columns.names()) {
98
+ const col = df.col(name);
99
+ if (!col) continue;
100
+ if (col.type !== DG.COLUMN_TYPE.FLOAT && col.type !== DG.COLUMN_TYPE.INT)
101
+ continue;
102
+ const lowerName = name.toLowerCase();
103
+ for (const prefix of INTENSITY_PREFIXES) {
104
+ if (lowerName.startsWith(prefix)) {
105
+ intensityNames.push(name);
106
+ break;
107
+ }
108
+ }
109
+ }
110
+ log2TransformColumns(df, intensityNames);
111
+ }
112
+
113
+ /** Assigns semantic types to protein ID and gene name columns. */
114
+ function assignSemanticTypes(df: DG.DataFrame): void {
115
+ const proteinCol = findCol(df, PROTEIN_ID_COLUMNS);
116
+ if (proteinCol)
117
+ proteinCol.semType = SEMTYPE.PROTEIN_ID;
118
+
119
+ const geneCol = findCol(df, GENE_NAME_COLUMNS);
120
+ if (geneCol)
121
+ geneCol.semType = SEMTYPE.GENE_SYMBOL;
122
+ }
123
+
124
+ /** Parses MaxQuant proteinGroups.txt TSV text into a filtered, typed DataFrame.
125
+ *
126
+ * Applies standard proteomics filtering:
127
+ * - Removes contaminant rows ('+' marker or CON__ prefix)
128
+ * - Removes reverse hits ('+' marker or REV__ prefix)
129
+ * - Removes only-identified-by-site rows
130
+ *
131
+ * Adds log2-transformed intensity columns and semantic type annotations. */
132
+ export async function parseMaxQuantText(text: string): Promise<DG.DataFrame> {
133
+ const columnImportOptions = buildIntensityColumnOptions(text);
134
+ const raw = DG.DataFrame.fromCsv(text, {delimiter: '\t', columnImportOptions});
135
+
136
+ // Apply row filters
137
+ raw.filter.init((_i) => true);
138
+ filterByMarker(raw, FILTER_COLUMNS.contaminant);
139
+ filterByMarker(raw, FILTER_COLUMNS.reverse);
140
+ filterByMarker(raw, FILTER_COLUMNS.onlyBySite);
141
+ filterByIdPrefix(raw);
142
+ raw.filter.fireChanged();
143
+
144
+ // Clone filtered rows
145
+ const df = raw.clone(raw.filter);
146
+
147
+ // Assign semantic types to ID and gene columns
148
+ assignSemanticTypes(df);
149
+
150
+ // Parse semicolon-delimited fields into primary columns (only if semicolons present)
151
+ addPrimaryColumnFromVariants(df, PROTEIN_ID_COLUMNS, 'Primary Protein ID', SEMTYPE.PROTEIN_ID);
152
+ addPrimaryColumnFromVariants(df, GENE_NAME_COLUMNS, 'Primary Gene Name', SEMTYPE.GENE_SYMBOL);
153
+
154
+ // Detect and log2-transform intensity columns
155
+ processIntensityColumns(df);
156
+
157
+ df.setTag('proteomics.source', 'maxquant');
158
+
159
+ await resolveGeneLabels(df);
160
+
161
+ return df;
162
+ }