@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
@@ -0,0 +1,169 @@
1
+ import * as DG from 'datagrok-api/dg';
2
+ import {GroupAssignment} from '../analysis/experiment-setup';
3
+ import {computePCA} from '../analysis/pca';
4
+
5
+ /** Local interface for area annotation region (may not be in installed datagrok-api types). */
6
+ interface AreaAnnotationRegionDef {
7
+ type: string;
8
+ area: [number, number][];
9
+ fillColor: string;
10
+ opacity: number;
11
+ outlineColor: string;
12
+ outlineWidth: number;
13
+ x: string;
14
+ y: string;
15
+ }
16
+
17
+ /** Group colors for PCA plot ellipses and styling. */
18
+ const GROUP_COLORS: string[] = ['#2196F3', '#FF5722'];
19
+
20
+ /** Compute 95% confidence ellipse polygon points for a set of 2D points.
21
+ * Returns an array of [x, y] pairs approximating the ellipse. */
22
+ function confidenceEllipse(
23
+ xVals: number[],
24
+ yVals: number[],
25
+ nPoints: number = 64,
26
+ ): [number, number][] | null {
27
+ const n = xVals.length;
28
+ if (n < 3)
29
+ return null;
30
+
31
+ // Compute means
32
+ let cx = 0;
33
+ let cy = 0;
34
+ for (let i = 0; i < n; i++) {
35
+ cx += xVals[i];
36
+ cy += yVals[i];
37
+ }
38
+ cx /= n;
39
+ cy /= n;
40
+
41
+ // Compute 2x2 covariance matrix
42
+ let sxx = 0;
43
+ let sxy = 0;
44
+ let syy = 0;
45
+ for (let i = 0; i < n; i++) {
46
+ const dx = xVals[i] - cx;
47
+ const dy = yVals[i] - cy;
48
+ sxx += dx * dx;
49
+ sxy += dx * dy;
50
+ syy += dy * dy;
51
+ }
52
+ sxx /= (n - 1);
53
+ sxy /= (n - 1);
54
+ syy /= (n - 1);
55
+
56
+ // Eigendecompose 2x2 matrix analytically
57
+ const trace = sxx + syy;
58
+ const det = sxx * syy - sxy * sxy;
59
+ const disc = Math.sqrt(Math.max(trace * trace / 4 - det, 0));
60
+ const lambda1 = trace / 2 + disc;
61
+ const lambda2 = trace / 2 - disc;
62
+
63
+ if (lambda1 <= 0 || lambda2 <= 0)
64
+ return null;
65
+
66
+ // Eigenvector for lambda1
67
+ let angle: number;
68
+ if (Math.abs(sxy) > 1e-12)
69
+ angle = Math.atan2(lambda1 - sxx, sxy);
70
+ else
71
+ angle = sxx >= syy ? 0 : Math.PI / 2;
72
+
73
+ // Chi-squared critical value for 2 DOF at 95% confidence
74
+ const chiSq95 = 5.991;
75
+
76
+ // Semi-axes scaled by chi-squared
77
+ const a = Math.sqrt(lambda1 * chiSq95);
78
+ const b = Math.sqrt(lambda2 * chiSq95);
79
+
80
+ // Generate ellipse polygon
81
+ const points: [number, number][] = [];
82
+ const cosA = Math.cos(angle);
83
+ const sinA = Math.sin(angle);
84
+ for (let i = 0; i < nPoints; i++) {
85
+ const t = (2 * Math.PI * i) / nPoints;
86
+ const ex = a * Math.cos(t);
87
+ const ey = b * Math.sin(t);
88
+ const x = cx + cosA * ex - sinA * ey;
89
+ const y = cy + sinA * ex + cosA * ey;
90
+ points.push([x, y]);
91
+ }
92
+
93
+ return points;
94
+ }
95
+
96
+ /** Creates a PCA plot (ScatterPlotViewer) on a sample-level DataFrame.
97
+ * Computes PCA from intensity columns and colors points by experimental group.
98
+ * Returns the viewer; the caller is responsible for adding the pcaDf to a view. */
99
+ export function createPcaPlot(
100
+ df: DG.DataFrame,
101
+ intensityCols: string[],
102
+ groupAssignment: GroupAssignment,
103
+ title?: string,
104
+ ): {viewer: DG.ScatterPlotViewer; pcaDf: DG.DataFrame} {
105
+ const {pcaDf, varianceExplained} = computePCA(df, intensityCols, groupAssignment);
106
+
107
+ // Find PC column names (they include variance in the name).
108
+ const pc1ColName = pcaDf.columns.toList().find((c) => c.name.startsWith('PC1'))?.name;
109
+ const pc2ColName = pcaDf.columns.toList().find((c) => c.name.startsWith('PC2'))?.name;
110
+ if (!pc1ColName || !pc2ColName)
111
+ throw new Error('PCA did not produce PC1/PC2 columns — too few samples?');
112
+
113
+ const sp = pcaDf.plot.scatter({
114
+ x: pc1ColName,
115
+ y: pc2ColName,
116
+ color: 'Group',
117
+ });
118
+
119
+ // Label all sample points (typically <50 samples in proteomics)
120
+ sp.props.labelColumnNames = ['SampleName'];
121
+ sp.props.displayLabels = 'Always';
122
+
123
+ // Attempt to add 95% confidence ellipses per group
124
+ try {
125
+ const pc1Col = pcaDf.col(pc1ColName)!;
126
+ const pc2Col = pcaDf.col(pc2ColName)!;
127
+ const groupCol = pcaDf.col('Group')!;
128
+ const groups = [groupAssignment.group1.name, groupAssignment.group2.name];
129
+
130
+ for (let g = 0; g < groups.length; g++) {
131
+ const groupName = groups[g];
132
+ const xVals: number[] = [];
133
+ const yVals: number[] = [];
134
+ for (let i = 0; i < pcaDf.rowCount; i++) {
135
+ if (groupCol.get(i) === groupName) {
136
+ xVals.push(pc1Col.get(i) as number);
137
+ yVals.push(pc2Col.get(i) as number);
138
+ }
139
+ }
140
+
141
+ const ellipsePoints = confidenceEllipse(xVals, yVals);
142
+ if (ellipsePoints) {
143
+ const color = GROUP_COLORS[g % GROUP_COLORS.length];
144
+ const region: AreaAnnotationRegionDef = {
145
+ type: 'area',
146
+ area: ellipsePoints,
147
+ fillColor: color,
148
+ opacity: 0.15,
149
+ outlineColor: color,
150
+ outlineWidth: 1,
151
+ x: pc1ColName,
152
+ y: pc2ColName,
153
+ };
154
+ // annotationRegions API may not be available in all datagrok-api versions
155
+ (sp.meta as any).annotationRegions?.add(region);
156
+ }
157
+ }
158
+ } catch (e: any) {
159
+ // Ellipse rendering is best-effort — the annotationRegions API may not be
160
+ // exposed in the installed datagrok-api. Log the underlying error so this
161
+ // doesn't silently mask unrelated bugs in the ellipse math or column reads.
162
+ console.warn('PCA: could not add confidence ellipses:', e?.message ?? e);
163
+ }
164
+
165
+ if (title)
166
+ sp.setOptions({title});
167
+
168
+ return {viewer: sp, pcaDf};
169
+ }
@@ -0,0 +1,266 @@
1
+ import * as DG from 'datagrok-api/dg';
2
+ import {SEMTYPE} from '../utils/proteomics-types';
3
+ import {findColumn} from '../utils/column-detection';
4
+ import {GroupAssignment} from '../analysis/experiment-setup';
5
+
6
+ /** Returns log2-prefixed intensity column names. */
7
+ export function getIntensityColumns(df: DG.DataFrame): string[] {
8
+ return df.columns.toList()
9
+ .filter((c) => c.semType === SEMTYPE.INTENSITY && c.name.startsWith('log2('))
10
+ .map((c) => c.name);
11
+ }
12
+
13
+ /** Computes the mean of non-null values across columns at a given row index.
14
+ * Returns NaN if all values are null. */
15
+ function groupMean(cols: DG.Column[], rowIdx: number): number {
16
+ let sum = 0;
17
+ let count = 0;
18
+ for (const col of cols) {
19
+ if (!col.isNone(rowIdx)) {
20
+ sum += col.get(rowIdx) as number;
21
+ count++;
22
+ }
23
+ }
24
+ return count > 0 ? sum / count : NaN;
25
+ }
26
+
27
+ /** Ensures a column exists fresh -- removes it if present, then adds a new float column. */
28
+ function ensureFreshFloat(df: DG.DataFrame, name: string): DG.Column {
29
+ if (df.columns.contains(name))
30
+ df.columns.remove(name);
31
+ return df.columns.addNewFloat(name);
32
+ }
33
+
34
+ /** Computes MA values (M = log2 ratio, A = average intensity) and adds M, A columns to df. */
35
+ export function computeMA(df: DG.DataFrame, groups: GroupAssignment): void {
36
+ const g1Cols = groups.group1.columns.map((n) => df.col(n)!);
37
+ const g2Cols = groups.group2.columns.map((n) => df.col(n)!);
38
+
39
+ const nRows = df.rowCount;
40
+ const mArr = new Float32Array(nRows);
41
+ const aArr = new Float32Array(nRows);
42
+ for (let i = 0; i < nRows; i++) {
43
+ const g1Mean = groupMean(g1Cols, i);
44
+ const g2Mean = groupMean(g2Cols, i);
45
+ if (isNaN(g1Mean) || isNaN(g2Mean)) {
46
+ mArr[i] = DG.FLOAT_NULL;
47
+ aArr[i] = DG.FLOAT_NULL;
48
+ } else {
49
+ mArr[i] = g2Mean - g1Mean;
50
+ aArr[i] = (g1Mean + g2Mean) / 2;
51
+ }
52
+ }
53
+ ensureFreshFloat(df, 'M').init((i) => mArr[i]);
54
+ ensureFreshFloat(df, 'A').init((i) => aArr[i]);
55
+ }
56
+
57
+ /** Computes a moving-average trend line for the MA plot.
58
+ * Approximates loess by sorting on A and averaging M within a sliding window.
59
+ * Adds an MA_trend column to df. */
60
+ export function computeLoessTrend(df: DG.DataFrame, windowFraction: number = 0.1): void {
61
+ const aCol = df.col('A');
62
+ const mCol = df.col('M');
63
+ if (!aCol || !mCol) {
64
+ ensureFreshFloat(df, 'MA_trend');
65
+ return;
66
+ }
67
+
68
+ const aRaw = aCol.getRawData() as Float32Array | Float64Array;
69
+ const mRaw = mCol.getRawData() as Float32Array | Float64Array;
70
+
71
+ // Collect non-null (A, M) pairs with original row indices
72
+ const points: {idx: number; a: number; m: number}[] = [];
73
+ for (let i = 0; i < df.rowCount; i++) {
74
+ if (aRaw[i] !== DG.FLOAT_NULL && mRaw[i] !== DG.FLOAT_NULL)
75
+ points.push({idx: i, a: aRaw[i], m: mRaw[i]});
76
+ }
77
+
78
+ const trendArr = new Float32Array(df.rowCount);
79
+ trendArr.fill(DG.FLOAT_NULL);
80
+
81
+ // Guard: need at least 3 non-null points
82
+ if (points.length < 3) {
83
+ ensureFreshFloat(df, 'MA_trend').init((i) => trendArr[i]);
84
+ return;
85
+ }
86
+
87
+ // Sort by A value
88
+ points.sort((a, b) => a.a - b.a);
89
+
90
+ const halfWindow = Math.max(Math.round(points.length * windowFraction / 2), 5);
91
+
92
+ // For each point, compute mean M in a window centered on it, writing to trendArr
93
+ for (let i = 0; i < points.length; i++) {
94
+ const lo = Math.max(0, i - halfWindow);
95
+ const hi = Math.min(points.length - 1, i + halfWindow);
96
+ let sum = 0;
97
+ let count = 0;
98
+ for (let j = lo; j <= hi; j++) {
99
+ sum += points[j].m;
100
+ count++;
101
+ }
102
+ trendArr[points[i].idx] = sum / count;
103
+ }
104
+
105
+ ensureFreshFloat(df, 'MA_trend').init((i) => trendArr[i]);
106
+ }
107
+
108
+ /** Computes coefficient of variation (CV = sd/mean) on raw (non-log) intensities
109
+ * within a group of columns. Adds cvColName and meanColName float columns to df. */
110
+ export function computeCV(
111
+ df: DG.DataFrame,
112
+ groupCols: string[],
113
+ cvColName: string,
114
+ meanColName: string,
115
+ ): void {
116
+ const rawArrays = groupCols
117
+ .map((n) => df.col(n))
118
+ .filter((c) => c != null && c.type === DG.COLUMN_TYPE.FLOAT)
119
+ .map((c) => c!.getRawData() as Float32Array | Float64Array);
120
+
121
+ const nRows = df.rowCount;
122
+ const cvArr = new Float32Array(nRows);
123
+ const meanArr = new Float32Array(nRows);
124
+
125
+ // Welford's online algorithm for mean + sample variance.
126
+ // The naive `(sumSq - sum*sum/count) / (count-1)` form suffers from
127
+ // catastrophic cancellation when the values are large and the spread
128
+ // is small — which is exactly the regime for raw intensities (often >1e6
129
+ // with tight biological CVs).
130
+ for (let i = 0; i < nRows; i++) {
131
+ let count = 0;
132
+ let mean = 0;
133
+ let m2 = 0;
134
+ for (const raw of rawArrays) {
135
+ const v = raw[i];
136
+ if (v !== DG.FLOAT_NULL) {
137
+ // Exponentiate log2 values back to raw intensities for CV computation
138
+ const val = Math.pow(2, v);
139
+ count++;
140
+ const delta = val - mean;
141
+ mean += delta / count;
142
+ m2 += delta * (val - mean);
143
+ }
144
+ }
145
+ if (count < 1) {
146
+ cvArr[i] = DG.FLOAT_NULL;
147
+ meanArr[i] = DG.FLOAT_NULL;
148
+ } else if (count < 2) {
149
+ cvArr[i] = DG.FLOAT_NULL;
150
+ meanArr[i] = mean;
151
+ } else {
152
+ const variance = m2 / (count - 1);
153
+ const sd = Math.sqrt(Math.max(0, variance));
154
+ cvArr[i] = mean > 0 ? sd / mean : DG.FLOAT_NULL;
155
+ meanArr[i] = mean;
156
+ }
157
+ }
158
+
159
+ ensureFreshFloat(df, cvColName).init((i) => cvArr[i]);
160
+ ensureFreshFloat(df, meanColName).init((i) => meanArr[i]);
161
+ }
162
+
163
+ /** Creates a binary missingness matrix DataFrame (1 = present, 0 = missing).
164
+ * Includes protein ID column if available. */
165
+ export function createMissingnessMatrix(df: DG.DataFrame, intensityCols: string[]): DG.DataFrame {
166
+ const columns: DG.Column[] = [];
167
+
168
+ // Include protein ID column if available
169
+ const labelCol = findColumn(df, SEMTYPE.PROTEIN_ID, ['protein id', 'accession']);
170
+ if (labelCol)
171
+ columns.push(labelCol.clone());
172
+
173
+ // Binary columns: 1 = present, 0 = missing.
174
+ // Write directly into a fresh Int32Array, then wrap as the column.
175
+ for (const colName of intensityCols) {
176
+ const srcCol = df.col(colName)!;
177
+ const srcRaw = srcCol.type === DG.COLUMN_TYPE.FLOAT
178
+ ? srcCol.getRawData() as Float32Array | Float64Array
179
+ : null;
180
+ const data = new Int32Array(df.rowCount);
181
+ if (srcRaw) {
182
+ for (let i = 0; i < df.rowCount; i++)
183
+ data[i] = srcRaw[i] === DG.FLOAT_NULL ? 0 : 1;
184
+ } else {
185
+ for (let i = 0; i < df.rowCount; i++)
186
+ data[i] = srcCol.isNone(i) ? 0 : 1;
187
+ }
188
+ columns.push(DG.Column.fromInt32Array(colName, data));
189
+ }
190
+
191
+ const result = DG.DataFrame.fromColumns(columns);
192
+ result.name = 'Missing Values';
193
+ return result;
194
+ }
195
+
196
+ /** Creates a long-format DataFrame with columns: ProteinId, Sample, Intensity.
197
+ * Skips null intensity values to keep the DataFrame compact. */
198
+ export function unpivotIntensities(df: DG.DataFrame, intensityCols: string[]): DG.DataFrame {
199
+ const proteinIdCol = findColumn(df, SEMTYPE.PROTEIN_ID, ['protein id', 'accession']);
200
+
201
+ const ids: string[] = [];
202
+ const samples: string[] = [];
203
+ const intensities: number[] = [];
204
+
205
+ for (let i = 0; i < df.rowCount; i++) {
206
+ const proteinId = proteinIdCol ? (proteinIdCol.get(i) as string ?? String(i)) : String(i);
207
+ for (const colName of intensityCols) {
208
+ const col = df.col(colName)!;
209
+ if (!col.isNone(i)) {
210
+ ids.push(proteinId);
211
+ samples.push(colName);
212
+ intensities.push(col.get(i) as number);
213
+ }
214
+ }
215
+ }
216
+
217
+ const result = DG.DataFrame.fromColumns([
218
+ DG.Column.fromStrings('ProteinId', ids),
219
+ DG.Column.fromStrings('Sample', samples),
220
+ DG.Column.fromFloat32Array('Intensity', new Float32Array(intensities)),
221
+ ]);
222
+ result.name = 'Intensity Distributions';
223
+ return result;
224
+ }
225
+
226
+ /** Creates a sample-level DataFrame with columns: Sample, MissingPct, Group.
227
+ * Each row represents one intensity column with its missing value percentage. */
228
+ export function computeMissingBarData(
229
+ df: DG.DataFrame,
230
+ intensityCols: string[],
231
+ groups: GroupAssignment,
232
+ ): DG.DataFrame {
233
+ const sampleNames: string[] = [];
234
+ const missingPcts: number[] = [];
235
+ const groupNames: string[] = [];
236
+
237
+ const g1Set = new Set(groups.group1.columns);
238
+ const g2Set = new Set(groups.group2.columns);
239
+
240
+ for (const colName of intensityCols) {
241
+ const col = df.col(colName)!;
242
+ let nullCount = 0;
243
+ for (let i = 0; i < df.rowCount; i++) {
244
+ if (col.isNone(i))
245
+ nullCount++;
246
+ }
247
+
248
+ sampleNames.push(colName);
249
+ missingPcts.push((nullCount / df.rowCount) * 100);
250
+
251
+ if (g1Set.has(colName))
252
+ groupNames.push(groups.group1.name);
253
+ else if (g2Set.has(colName))
254
+ groupNames.push(groups.group2.name);
255
+ else
256
+ groupNames.push('Other');
257
+ }
258
+
259
+ const result = DG.DataFrame.fromColumns([
260
+ DG.Column.fromStrings('Sample', sampleNames),
261
+ DG.Column.fromFloat32Array('MissingPct', new Float32Array(missingPcts)),
262
+ DG.Column.fromStrings('Group', groupNames),
263
+ ]);
264
+ result.name = 'Missing Values per Sample';
265
+ return result;
266
+ }
@@ -0,0 +1,176 @@
1
+ import * as grok from 'datagrok-api/grok';
2
+ import * as DG from 'datagrok-api/dg';
3
+
4
+ import {getGroups} from '../analysis/experiment-setup';
5
+ import {ensureDirectionColumn} from './volcano';
6
+ import {DEFAULT_FC_THRESHOLD, DEFAULT_P_THRESHOLD} from '../utils/proteomics-types';
7
+ import {
8
+ getIntensityColumns,
9
+ computeMA,
10
+ computeLoessTrend,
11
+ computeCV,
12
+ createMissingnessMatrix,
13
+ unpivotIntensities,
14
+ computeMissingBarData,
15
+ } from './qc-computations';
16
+
17
+ /** Columns added by QC computations that should be cleaned up on re-run.
18
+ * `direction` is NOT included here: it is shared with the volcano plot, which
19
+ * updates it in-place, and ensureDirectionColumn(...) below overwrites cells
20
+ * rather than re-adding the column. Removing it would break any open volcano
21
+ * viewer bound to the column reference. */
22
+ const QC_COLUMNS = ['M', 'A', 'MA_trend', 'CV_group1', 'meanInt_group1', 'CV_group2', 'meanInt_group2'];
23
+
24
+ /** Opens a QC dashboard with all QC viewers docked in a tiled layout.
25
+ * Requires group annotations (shows warning if missing). */
26
+ export function openQcDashboard(df: DG.DataFrame): void {
27
+ const pi = DG.TaskBarProgressIndicator.create('Creating QC dashboard...');
28
+ try {
29
+ // 1. Prerequisite check: groups must be annotated
30
+ const groups = getGroups(df);
31
+ if (!groups) {
32
+ grok.shell.warning('Annotate experimental groups first (Proteomics | Annotate Experiment)');
33
+ return;
34
+ }
35
+
36
+ // 2. Clean up previous QC columns (allows re-running)
37
+ for (const name of QC_COLUMNS) {
38
+ if (df.columns.contains(name))
39
+ df.columns.remove(name);
40
+ }
41
+
42
+ // 3. Get intensity columns
43
+ const intensityCols = getIntensityColumns(df);
44
+ if (intensityCols.length === 0) {
45
+ grok.shell.warning('No log2 intensity columns found');
46
+ return;
47
+ }
48
+
49
+ // 4. Compute MA values
50
+ computeMA(df, groups);
51
+
52
+ // 5. Compute loess/moving-average trend
53
+ computeLoessTrend(df);
54
+
55
+ // 6. Compute CV per group
56
+ computeCV(df, groups.group1.columns, 'CV_group1', 'meanInt_group1');
57
+ computeCV(df, groups.group2.columns, 'CV_group2', 'meanInt_group2');
58
+
59
+ // Get the current table view
60
+ const tv = grok.shell.tv;
61
+ if (!tv) {
62
+ grok.shell.warning('No table view open');
63
+ return;
64
+ }
65
+
66
+ // 7. MA scatter plot
67
+ const maPlotOptions: Record<string, any> = {
68
+ xColumnName: 'A',
69
+ yColumnName: 'M',
70
+ title: 'MA Plot',
71
+ markerDefaultSize: 2,
72
+ };
73
+
74
+ // Conditional coloring by direction when DE is available
75
+ if (df.getTag('proteomics.de_complete') === 'true') {
76
+ try {
77
+ const dirColName = ensureDirectionColumn(df, DEFAULT_FC_THRESHOLD, DEFAULT_P_THRESHOLD);
78
+ maPlotOptions['colorColumnName'] = dirColName;
79
+ } catch (_e) {
80
+ // DE columns may not exist despite tag -- skip coloring
81
+ }
82
+ }
83
+
84
+ const maPlot = tv.addViewer(DG.VIEWER.SCATTER_PLOT, maPlotOptions);
85
+
86
+ // Add M=0 reference line for MA plot. Drop any prior M=0 lines first so
87
+ // repeated QC dashboard opens don't stack duplicates on the DataFrame.
88
+ const maFormula = '${M} = 0';
89
+ df.meta.formulaLines.items = df.meta.formulaLines.items.filter((line) => line.formula !== maFormula);
90
+ df.meta.formulaLines.addLine({
91
+ formula: maFormula,
92
+ color: '#888888',
93
+ width: 1,
94
+ visible: true,
95
+ });
96
+
97
+ // 7b. MA trend line as a separate scatter plot with line markers
98
+ const maTrend = tv.addViewer(DG.VIEWER.SCATTER_PLOT, {
99
+ xColumnName: 'A',
100
+ yColumnName: 'MA_trend',
101
+ title: 'MA Trend',
102
+ markerDefaultSize: 1,
103
+ });
104
+
105
+ // 8. CV scatter plot (group1 by default)
106
+ const cvPlot = tv.addViewer(DG.VIEWER.SCATTER_PLOT, {
107
+ xColumnName: 'meanInt_group1',
108
+ yColumnName: 'CV_group1',
109
+ title: 'CV Plot',
110
+ markerDefaultSize: 2,
111
+ });
112
+
113
+ // 9. Sample correlation plot
114
+ const corrPlot = tv.addViewer(DG.VIEWER.CORR_PLOT, {
115
+ xColumnNames: intensityCols,
116
+ yColumnNames: intensityCols,
117
+ title: 'Sample Correlation',
118
+ });
119
+
120
+ // 10. Missing values heatmap (separate DataFrame -- no addTable to avoid circular JSON)
121
+ const missDf = createMissingnessMatrix(df, intensityCols);
122
+ const missGrid = DG.Viewer.grid(missDf);
123
+ missGrid.props.allowColSelection = false;
124
+
125
+ // 11. Missing values bar chart (separate DataFrame -- use factory to bind correctly)
126
+ const barDf = computeMissingBarData(df, intensityCols, groups);
127
+ const missBar = DG.Viewer.barChart(barDf, {
128
+ splitColumnName: 'Sample',
129
+ valueColumnName: 'MissingPct',
130
+ stackColumnName: 'Group',
131
+ } as any);
132
+
133
+ // 12. Box plot for intensity distributions (separate DataFrame -- use factory to bind correctly)
134
+ const longDf = unpivotIntensities(df, intensityCols);
135
+ const boxPlot = DG.Viewer.boxPlot(longDf, {
136
+ valueColumnName: 'Intensity',
137
+ categoryColumnName: 'Sample',
138
+ } as any);
139
+
140
+ // 13. Dock layout -- two themed tab stacks so every chart stays full-size.
141
+ // Seven heterogeneous QC charts can't all be legible tiled at once (chaining
142
+ // them RIGHT squeezes the last ones to nothing), so group by QC theme and tab
143
+ // within each region (DOCK_TYPE.FILL): the active chart fills the whole
144
+ // region instead of being cramped.
145
+ // * Top-right region -- per-protein diagnostics: MA / MA trend / CV
146
+ // * Bottom-right region -- per-sample QC: correlation / distributions / missingness
147
+ // The data grid keeps the left quarter.
148
+ const dm = tv.dockManager;
149
+
150
+ // Per-protein stack (top-right). RIGHT of root at 0.75 leaves the grid a quarter.
151
+ const protNode = dm.dock(maPlot, DG.DOCK_TYPE.RIGHT, null, 'MA Plot', 0.75);
152
+ dm.dock(maTrend, DG.DOCK_TYPE.FILL, protNode, 'MA Trend');
153
+ dm.dock(cvPlot, DG.DOCK_TYPE.FILL, protNode, 'CV Plot');
154
+
155
+ // Per-sample stack (bottom-right), splitting the right region in half vertically.
156
+ const sampleNode = dm.dock(corrPlot, DG.DOCK_TYPE.DOWN, protNode, 'Sample Correlation', 0.5);
157
+ dm.dock(boxPlot, DG.DOCK_TYPE.FILL, sampleNode, 'Intensity Distributions');
158
+ dm.dock(missBar, DG.DOCK_TYPE.FILL, sampleNode, 'Missing % per Sample');
159
+ dm.dock(missGrid, DG.DOCK_TYPE.FILL, sampleNode, 'Missing Values');
160
+
161
+ // FILL docking activates the LAST-docked tab; make each stack default to its
162
+ // headline chart instead (MA Plot / Sample Correlation). setActiveChild lives
163
+ // on the parent tab container. Best-effort — a layout that isn't tabbed (e.g.
164
+ // a future single-viewer variant) simply keeps its default active tab.
165
+ const activateTab = (v: DG.Viewer): void => {
166
+ try {
167
+ const node = dm.findNode(v.root);
168
+ if (node?.parent) node.parent.container.setActiveChild(node.container);
169
+ } catch { /* best-effort */ }
170
+ };
171
+ activateTab(maPlot);
172
+ activateTab(corrPlot);
173
+ } finally {
174
+ pi.close();
175
+ }
176
+ }