@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
|
@@ -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
|
+
}
|