@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.
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 -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 +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,690 @@
1
+ import * as DG from 'datagrok-api/dg';
2
+ import * as ui from 'datagrok-api/ui';
3
+ import * as rxjs from 'rxjs';
4
+ import {debounceTime} from 'rxjs/operators';
5
+ import {findColumn} from '../utils/column-detection';
6
+ import {SEMTYPE, DIRECTION_COLORS_BASE, DEFAULT_FC_THRESHOLD,
7
+ DEFAULT_P_THRESHOLD} from '../utils/proteomics-types';
8
+ import {parseAccession} from '../panels/uniprot-panel';
9
+ import {getGroups, getOrganism} from '../analysis/experiment-setup';
10
+ import {
11
+ getSubcellularLocations,
12
+ LOCATION_COLORS,
13
+ ProgressCb,
14
+ } from '../analysis/subcellular-location';
15
+
16
+ /** -log10 of the smallest representable positive double, used as a sentinel for
17
+ * p-values that underflow to literal 0 (e.g. from t-test extreme inputs).
18
+ * Plotting at this height keeps the point visible but clearly off-scale,
19
+ * rather than colliding with real values like p = 1e-10. */
20
+ const UNDERFLOW_NEGLOG10 = -Math.log10(Number.MIN_VALUE);
21
+
22
+ /** Significance metric the volcano Y axis / classification is computed from. */
23
+ export type MetricKind = 'adj.p-value' | 'p-value';
24
+
25
+ /** Active color dimension on the volcano (significance categories vs subcellular location). */
26
+ export type ColorDim = 'significance' | 'location';
27
+
28
+ /** Stable, generic column names. The Y/color bindings keep these names across
29
+ * a metric/color toggle; only the values change in place (Pitfall 5 — never
30
+ * early-return on existence, never remove+re-add a bound column). */
31
+ const NEG_LOG_COL = 'negLog10P';
32
+ const DIRECTION_COL = 'direction';
33
+ export const LOCATION_COL = 'Subcellular Location';
34
+
35
+ /** Persists the active metric on the DataFrame so the Volcano Options dialog
36
+ * (G2) and the axis-label overlay (G1) can derive Y-axis text without
37
+ * re-reading scatter props. recomputeVolcano updates this on every toggle. */
38
+ export const VOLCANO_METRIC_TAG = 'proteomics.volcano_metric';
39
+
40
+ /** Module-level subscription store mirrors `enrichment-viewers.ts:9` —
41
+ * disposed at the start of every createVolcanoPlot to prevent stacked
42
+ * overlays / frozen counters on viewer re-entry (Pitfall 6). */
43
+ let activeVolcanoSubscriptions: rxjs.Subscription[] = [];
44
+
45
+ /** Marker class on a volcano's scatter root; scopes the axis-chip-hiding
46
+ * stylesheet so it only affects our volcanoes, never other scatterplots. */
47
+ const VOLCANO_ROOT_CLASS = 'proteomics-volcano';
48
+
49
+ /** Injects the scoped stylesheet that hides the platform's X/Y axis
50
+ * column-selector chips inside a volcano (they duplicated our custom axis
51
+ * labels). `.d4-vertical` (Y) and `.d4-bottom-center` (X) only — the color
52
+ * (`.d4-vertical-right`) and size selectors stay. Idempotent: injected once. */
53
+ function ensureVolcanoStyles(): void {
54
+ const id = 'proteomics-volcano-styles';
55
+ if (document.getElementById(id)) return;
56
+ const style = document.createElement('style');
57
+ style.id = id;
58
+ style.textContent =
59
+ `.${VOLCANO_ROOT_CLASS} .d4-column-selector.d4-vertical,` +
60
+ `.${VOLCANO_ROOT_CLASS} .d4-column-selector.d4-bottom-center{display:none !important;}`;
61
+ document.head.appendChild(style);
62
+ }
63
+
64
+ /** Resolves the significance column for the requested metric. `p-value` falls
65
+ * back to `adj.p-value` when the raw p-value column is absent (D-06 guard — no
66
+ * throw, stays on adj.p-value). Throws only when adj.p-value itself is missing
67
+ * (genuinely no DE result). */
68
+ function pickMetricColumn(df: DG.DataFrame, metric: MetricKind): DG.Column {
69
+ if (metric === 'p-value') {
70
+ const p = df.col('p-value');
71
+ if (p) return p;
72
+ }
73
+ const adj = df.col('adj.p-value');
74
+ if (!adj)
75
+ throw new Error('adj.p-value column not found');
76
+ return adj;
77
+ }
78
+
79
+ /** Ensures the -log10(metric) column exists and reflects `metric`. The column
80
+ * name is FIXED so the scatter Y binding is stable; values are ALWAYS re-init
81
+ * in place so a metric toggle updates the axis without rebinding. */
82
+ export function ensureNegLog10Column(
83
+ df: DG.DataFrame, metric: MetricKind = 'adj.p-value',
84
+ ): string {
85
+ const pCol = pickMetricColumn(df, metric);
86
+ const col = df.col(NEG_LOG_COL) ?? df.columns.addNewFloat(NEG_LOG_COL);
87
+ col.init((i) => {
88
+ if (pCol.isNone(i)) return DG.FLOAT_NULL;
89
+ const p = pCol.get(i) as number;
90
+ return p > 0 ? -Math.log10(p) : UNDERFLOW_NEGLOG10;
91
+ });
92
+ return NEG_LOG_COL;
93
+ }
94
+
95
+ /** LOCKED color contract (D-04, CONTEXT.md §"Specifics"): magenta = numerator
96
+ * (group1), cyan = denominator (group2), gray = NS. Keys are derived per call
97
+ * from `getGroups(df)`; the ARGB ints are the invariant base, now sourced from
98
+ * `proteomics-types` and re-exported here for existing importers. */
99
+ export {DIRECTION_COLORS_BASE};
100
+
101
+ /** Ensures the direction column, classified against the chosen `metric` (same
102
+ * column the Y axis uses, so dots and coloring never disagree). Updated in
103
+ * place — never remove+re-add (preserves viewer/grid bindings). D-04 migration:
104
+ * category strings derive from `getGroups(df)` (`Enriched in {g1}` / `Enriched
105
+ * in {g2}` / `Not significant`), color map is the LOCKED magenta/cyan/gray. */
106
+ export function ensureDirectionColumn(
107
+ df: DG.DataFrame,
108
+ fcThreshold: number,
109
+ pThreshold: number,
110
+ metric: MetricKind = 'adj.p-value',
111
+ ): string {
112
+ const fcCol = df.col('log2FC');
113
+ if (!fcCol)
114
+ throw new Error('log2FC column not found');
115
+ const pCol = pickMetricColumn(df, metric);
116
+
117
+ const groups = getGroups(df);
118
+ const g1Label = groups ? `Enriched in ${groups.group1.name}` : 'Enriched in group1';
119
+ const g2Label = groups ? `Enriched in ${groups.group2.name}` : 'Enriched in group2';
120
+ const nsLabel = 'Not significant';
121
+
122
+ const n = df.rowCount;
123
+ const vals: string[] = new Array(n);
124
+ for (let i = 0; i < n; i++) {
125
+ if (fcCol.isNone(i) || pCol.isNone(i)) { vals[i] = nsLabel; continue; }
126
+ const fc = fcCol.get(i) as number;
127
+ const p = pCol.get(i) as number;
128
+ if (p <= pThreshold && fc > fcThreshold) vals[i] = g1Label;
129
+ else if (p <= pThreshold && fc < -fcThreshold) vals[i] = g2Label;
130
+ else vals[i] = nsLabel;
131
+ }
132
+
133
+ const col = df.col(DIRECTION_COL) ?? df.columns.addNewString(DIRECTION_COL);
134
+ col.init((i) => vals[i]);
135
+ col.meta.colors.setCategorical({
136
+ [g1Label]: DIRECTION_COLORS_BASE.enrichedG1,
137
+ [g2Label]: DIRECTION_COLORS_BASE.enrichedG2,
138
+ [nsLabel]: DIRECTION_COLORS_BASE.notSig,
139
+ });
140
+ return DIRECTION_COL;
141
+ }
142
+
143
+ /** FNV-1a 32-bit hex hash. Deterministic, dependency-free, fast enough for the
144
+ * ~8k-accession stable-sorted join that ensureLocationColumn feeds it. Used
145
+ * only as a cache-invalidation key on the Subcellular Location column. */
146
+ function fnv1aHex(s: string): string {
147
+ let h = 0x811c9dc5;
148
+ for (let i = 0; i < s.length; i++) {
149
+ h ^= s.charCodeAt(i);
150
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
151
+ }
152
+ return h.toString(16).padStart(8, '0');
153
+ }
154
+
155
+ /** Cache-invalidation key on the Subcellular Location COLUMN (not the
156
+ * DataFrame). This is the first column-level tag in the package — the
157
+ * DataFrame tag convention covers whole-pipeline workflow state, while
158
+ * per-column cache-invalidation metadata belongs on the column. Documented
159
+ * in 13-08 SUMMARY.md as a new convention. */
160
+ const LOCATION_HASH_TAG = 'proteomics.location_acc_hash';
161
+
162
+ /** Ensures the 'Subcellular Location' categorical column using the shared
163
+ * verbatim-CK-omics service (13-04). Bulk-init string column, the LOCKED 11+1
164
+ * palette via setCategorical, semType = SUBCELLULAR_LOCATION. In place.
165
+ *
166
+ * Short-circuits when the column already exists AND its accession-set hash
167
+ * tag matches the current accession set — skipping the entire fetch+init
168
+ * pipeline. The palette and semType survive on the column object across
169
+ * the short-circuit because `col.meta.colors.setCategorical` and
170
+ * `col.semType` are set on the same column object as last run.
171
+ *
172
+ * `progress` is forwarded to getSubcellularLocations for the fetch phases
173
+ * AND fired once for the 'init-column' phase so the dialog's pi can advance
174
+ * past the post-fetch bulk-init. */
175
+ export async function ensureLocationColumn(
176
+ df: DG.DataFrame, progress?: ProgressCb,
177
+ ): Promise<string> {
178
+ const idCol = findColumn(df, SEMTYPE.PROTEIN_ID,
179
+ ['primary protein id', 'protein id', 'protein ids', 'accession', 'uniprot']) ??
180
+ df.col('Primary Protein ID') ?? df.col('Protein ID');
181
+
182
+ const n = df.rowCount;
183
+ const accForRow: (string | null)[] = new Array(n).fill(null);
184
+ const accessions = new Set<string>();
185
+ if (idCol) {
186
+ for (let i = 0; i < n; i++) {
187
+ const raw = idCol.get(i) as string | null;
188
+ const acc = raw ? parseAccession(raw) : null;
189
+ accForRow[i] = acc;
190
+ if (acc) accessions.add(acc);
191
+ }
192
+ }
193
+
194
+ // Stable-sorted join → deterministic across calls regardless of row order.
195
+ const currentHash = fnv1aHex([...accessions].sort().join('|'));
196
+
197
+ const existing = df.col(LOCATION_COL);
198
+ if (existing && existing.getTag(LOCATION_HASH_TAG) === currentHash) {
199
+ // Short-circuit: column already populated for this exact accession set.
200
+ // Fire 'init-column' so the dialog's pi can advance to 100% and close
201
+ // cleanly without sitting at 0%.
202
+ progress?.(1, 1, 'init-column');
203
+ return LOCATION_COL;
204
+ }
205
+
206
+ // Organism (set by the enrichment dialog, persisted as a df tag) narrows the
207
+ // gene-fallback pass so non-human data isn't mis-colored from human entries.
208
+ const locByAcc = await getSubcellularLocations(
209
+ [...accessions], getOrganism(df), progress);
210
+ const vals: string[] = new Array(n);
211
+ for (let i = 0; i < n; i++) {
212
+ const acc = accForRow[i];
213
+ vals[i] = (acc && locByAcc.get(acc)) || 'Unknown';
214
+ }
215
+
216
+ const col = existing ?? df.columns.addNewString(LOCATION_COL);
217
+ col.init((i) => vals[i]);
218
+ col.meta.colors.setCategorical(LOCATION_COLORS);
219
+ col.semType = SEMTYPE.SUBCELLULAR_LOCATION;
220
+ // Column-level cache-invalidation tag — see plan 13-08 patterns-established.
221
+ col.setTag(LOCATION_HASH_TAG, currentHash);
222
+ // Final progress tick after bulk-init so the dialog's pi advances past
223
+ // the network phase into 'init-column' done.
224
+ progress?.(1, 1, 'init-column');
225
+ return LOCATION_COL;
226
+ }
227
+
228
+ /** Replaces (never stacks) the volcano threshold lines: horizontal at the
229
+ * significance cutoff and the ±fold-change verticals. */
230
+ function applyThresholdLines(
231
+ df: DG.DataFrame, yColName: string, fcThreshold: number, pThreshold: number,
232
+ ): void {
233
+ const hLine = -Math.log10(pThreshold);
234
+ const yFormulaPrefix = `\${${yColName}}`;
235
+ const fcFormulaPrefix = `\${log2FC}`;
236
+ df.meta.formulaLines.items = df.meta.formulaLines.items.filter((line) => {
237
+ const f = line.formula ?? '';
238
+ return !(typeof f === 'string' &&
239
+ (f.startsWith(yFormulaPrefix) || f.startsWith(fcFormulaPrefix)));
240
+ });
241
+ df.meta.formulaLines.addLine(
242
+ {formula: `${yFormulaPrefix} = ${hLine}`, color: '#888888', width: 1, visible: true});
243
+ df.meta.formulaLines.addLine(
244
+ {formula: `${fcFormulaPrefix} = ${fcThreshold}`, color: '#888888', width: 1, visible: true});
245
+ df.meta.formulaLines.addLine(
246
+ {formula: `${fcFormulaPrefix} = ${-fcThreshold}`, color: '#888888', width: 1, visible: true});
247
+ }
248
+
249
+ /** UI-SPEC §"Copywriting": Y-axis label depends on the active metric;
250
+ * defaults to adj.p-value mode when the tag is absent. */
251
+ function yAxisLabelForMetric(metric: MetricKind | null | undefined): string {
252
+ return metric === 'p-value' ? '-Log10(p-value)' : '-Log10(Q-value)';
253
+ }
254
+
255
+ /** G2 dialog-preload contract: every input in the Volcano Options dialog is
256
+ * seeded from {@link readVolcanoState}. Source-of-truth precedence:
257
+ * 1. `sp.getOptions().look.colorColumnName` → colorDim
258
+ * 2. `df.tag(VOLCANO_METRIC_TAG)` → metric (set by recomputeVolcano)
259
+ * Pitfall 2 — the dialog snapshots at open time; OK uses input values, never
260
+ * a re-read of sp.getOptions. */
261
+ export interface VolcanoState {
262
+ metric: MetricKind;
263
+ colorDim: ColorDim;
264
+ }
265
+
266
+ export function readVolcanoState(df: DG.DataFrame, sp: DG.ScatterPlotViewer): VolcanoState {
267
+ const opts = sp.getOptions() as any;
268
+ const look = opts?.look ?? {};
269
+ const colorDim: ColorDim = look.colorColumnName === LOCATION_COL ? 'location' : 'significance';
270
+ const metric = (df.getTag(VOLCANO_METRIC_TAG) as MetricKind | null) ?? 'adj.p-value';
271
+ return {metric, colorDim};
272
+ }
273
+
274
+ /** Volcano title synthesis from `proteomics.groups` per UI-SPEC §"Copywriting":
275
+ * `Volcano Plot: {g1} vs {g2}`, or `Volcano Plot` when groups are unset. */
276
+ function synthesizeVolcanoTitle(df: DG.DataFrame): string {
277
+ const groups = getGroups(df);
278
+ return groups ?
279
+ `Volcano Plot: ${groups.group1.name} vs ${groups.group2.name}` :
280
+ 'Volcano Plot';
281
+ }
282
+
283
+ /** Disposes every subscription in `activeVolcanoSubscriptions` and removes any
284
+ * axis-label / counter overlays attached to `sp.root`. Called at the start of
285
+ * every createVolcanoPlot so re-entry never stacks DOM or strands subscribers
286
+ * pointing at a detached viewer (Pitfall 6). */
287
+ function disposeVolcanoAttachments(sp?: DG.ScatterPlotViewer): void {
288
+ for (const s of activeVolcanoSubscriptions) s.unsubscribe();
289
+ activeVolcanoSubscriptions = [];
290
+ if (sp) {
291
+ for (const sel of [
292
+ '[data-volcano-axis-x]', '[data-volcano-axis-y]', '[data-volcano-counter]',
293
+ '[data-volcano-busy]',
294
+ ])
295
+ sp.root.querySelectorAll(sel).forEach((el) => el.remove());
296
+ }
297
+ }
298
+
299
+ /** Re-reads the Y-axis label DOM (if present) from the current metric tag.
300
+ * Called by recomputeVolcano after the tag is persisted so the label reflects
301
+ * the new metric immediately (sp.onPropertyChanged doesn't fire when only the
302
+ * tag changes — the yColumnName stays stable). */
303
+ function refreshVolcanoAxisLabel(sp: DG.ScatterPlotViewer, df: DG.DataFrame): void {
304
+ const yLabel = sp.root.querySelector<HTMLElement>('[data-volcano-axis-y]');
305
+ if (yLabel) {
306
+ const metric = df.getTag(VOLCANO_METRIC_TAG) as MetricKind | null;
307
+ yLabel.textContent = yAxisLabelForMetric(metric);
308
+ }
309
+ }
310
+
311
+ /** D-06 live counter overlay: floating div anchored bottom-right of the
312
+ * volcano. Heading + Total + per-category counts (significance categories
313
+ * when color = direction, subcellular location categories when color =
314
+ * Subcellular Location). Counts iterate `df.filter` only (selection toggles
315
+ * trigger a recompute defensively but never change the displayed counts —
316
+ * CONTEXT D-06). Subscriptions are pushed into `activeVolcanoSubscriptions`
317
+ * so disposeVolcanoAttachments tears them down on re-entry (Pitfall 6). */
318
+ function attachCounterOverlay(sp: DG.ScatterPlotViewer, df: DG.DataFrame): void {
319
+ const host = sp.root;
320
+ host.style.position = host.style.position || 'relative';
321
+
322
+ const overlay = ui.divV([]);
323
+ overlay.dataset.volcanoCounter = 'true';
324
+ Object.assign(overlay.style, {
325
+ position: 'absolute', bottom: '8px', right: '8px',
326
+ padding: '8px 16px', background: 'rgba(255,255,255,0.85)',
327
+ borderRadius: '4px', fontSize: '0.9em',
328
+ pointerEvents: 'none', zIndex: '5',
329
+ } as Partial<CSSStyleDeclaration>);
330
+ host.appendChild(overlay);
331
+
332
+ const recompute = (): void => {
333
+ const total = df.filter.trueCount;
334
+ const colorColName = sp.props.colorColumnName;
335
+ const colorCol = colorColName ? df.col(colorColName) : null;
336
+
337
+ // Iterate categories from the column's own category list so zero-count
338
+ // categories still render (D-06: empty filter still shows zeros, not
339
+ // just the categories present in the filtered subset).
340
+ const counts = new Map<string, number>();
341
+ let perCategoryEnabled = false;
342
+ if (colorCol && (colorColName === DIRECTION_COL || colorColName === LOCATION_COL)) {
343
+ perCategoryEnabled = true;
344
+ for (const cat of colorCol.categories) counts.set(cat, 0);
345
+ for (let i = 0; i < df.rowCount; i++) {
346
+ if (!df.filter.get(i)) continue;
347
+ if (colorCol.isNone(i)) continue;
348
+ const cat = colorCol.get(i) as string;
349
+ counts.set(cat, (counts.get(cat) ?? 0) + 1);
350
+ }
351
+ }
352
+
353
+ overlay.replaceChildren();
354
+ const heading = ui.divText('Visible Proteins');
355
+ heading.style.fontWeight = 'bold';
356
+ overlay.appendChild(heading);
357
+ overlay.appendChild(ui.divText(`Total: ${total.toLocaleString()}`));
358
+ if (perCategoryEnabled) {
359
+ for (const [cat, n] of counts)
360
+ overlay.appendChild(ui.divText(`${cat}: ${n.toLocaleString()}`));
361
+ }
362
+ };
363
+
364
+ recompute();
365
+ activeVolcanoSubscriptions.push(
366
+ df.onFilterChanged.pipe(debounceTime(50)).subscribe(recompute),
367
+ df.onSelectionChanged.pipe(debounceTime(50)).subscribe(recompute),
368
+ sp.onPropertyValueChanged.pipe(debounceTime(50)).subscribe(recompute),
369
+ );
370
+ }
371
+
372
+ /** Attaches DOM overlays for X and Y axis labels (the scatter has no
373
+ * xAxisCustomTitle / yAxisCustomTitle prop). X label is invariant; Y label is
374
+ * driven from VOLCANO_METRIC_TAG and re-rendered by refreshVolcanoAxisLabel. */
375
+ function attachAxisLabels(sp: DG.ScatterPlotViewer, df: DG.DataFrame): void {
376
+ const host = sp.root;
377
+ host.style.position = host.style.position || 'relative';
378
+
379
+ const xLabel = ui.divText('Log2 Fold Change');
380
+ xLabel.dataset.volcanoAxisX = 'true';
381
+ Object.assign(xLabel.style, {
382
+ position: 'absolute', bottom: '4px', left: '50%',
383
+ transform: 'translateX(-50%)', fontSize: '0.85em',
384
+ pointerEvents: 'none', background: 'rgba(255,255,255,0.7)',
385
+ padding: '0 4px', zIndex: '4',
386
+ } as Partial<CSSStyleDeclaration>);
387
+ host.appendChild(xLabel);
388
+
389
+ const metric = df.getTag(VOLCANO_METRIC_TAG) as MetricKind | null;
390
+ const yLabel = ui.divText(yAxisLabelForMetric(metric));
391
+ yLabel.dataset.volcanoAxisY = 'true';
392
+ Object.assign(yLabel.style, {
393
+ position: 'absolute', left: '4px', top: '50%',
394
+ transform: 'translateY(-50%) rotate(-90deg)',
395
+ transformOrigin: 'left center', fontSize: '0.85em',
396
+ pointerEvents: 'none', background: 'rgba(255,255,255,0.7)',
397
+ padding: '0 4px', zIndex: '4',
398
+ } as Partial<CSSStyleDeclaration>);
399
+ host.appendChild(yLabel);
400
+
401
+ // Defensive: refresh on any property change as a secondary trigger
402
+ // (recomputeVolcano calls refreshVolcanoAxisLabel directly — this catches
403
+ // any external prop edit, e.g. user changes color column via property panel).
404
+ activeVolcanoSubscriptions.push(
405
+ sp.onPropertyValueChanged.pipe(debounceTime(50))
406
+ .subscribe(() => refreshVolcanoAxisLabel(sp, df)),
407
+ );
408
+ }
409
+
410
+ /** 13-10 in-volcano busy overlay. Centered card on `sp.root` showing a phase
411
+ * label and an optional detail line; updated per ProgressCb tick from the
412
+ * caller (e.g. volcanoOptions during a first-time subcellular-location fetch).
413
+ *
414
+ * The TaskBarProgressIndicator at the bottom of the platform shell is too easy
415
+ * to miss while the user stares at a stale-looking volcano (post-13-08 UAT
416
+ * Test 3). This overlay anchors progress on the surface the user is actually
417
+ * watching. Tagged with `data-volcano-busy` so disposeVolcanoAttachments
418
+ * sweeps it on createVolcanoPlot re-entry. */
419
+ export function showVolcanoBusy(sp: DG.ScatterPlotViewer, label: string): void {
420
+ // Idempotent: replace any pre-existing overlay rather than stacking.
421
+ hideVolcanoBusy(sp);
422
+
423
+ const host = sp.root;
424
+ host.style.position = host.style.position || 'relative';
425
+
426
+ const overlay = ui.divV([]);
427
+ overlay.dataset.volcanoBusy = 'true';
428
+ Object.assign(overlay.style, {
429
+ position: 'absolute', top: '50%', left: '50%',
430
+ transform: 'translate(-50%, -50%)',
431
+ padding: '12px 20px', background: 'rgba(255,255,255,0.95)',
432
+ border: '1px solid #ccc', borderRadius: '6px',
433
+ boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
434
+ fontSize: '0.95em', textAlign: 'center',
435
+ pointerEvents: 'none', zIndex: '6',
436
+ maxWidth: '320px',
437
+ } as Partial<CSSStyleDeclaration>);
438
+
439
+ const labelDiv = ui.divText(label);
440
+ labelDiv.style.fontWeight = 'bold';
441
+ labelDiv.style.marginBottom = '4px';
442
+ const detailDiv = ui.divText('');
443
+ detailDiv.style.fontSize = '0.85em';
444
+ detailDiv.style.color = '#666';
445
+ overlay.appendChild(labelDiv);
446
+ overlay.appendChild(detailDiv);
447
+ host.appendChild(overlay);
448
+ }
449
+
450
+ export function updateVolcanoBusy(
451
+ sp: DG.ScatterPlotViewer, label: string, detail?: string,
452
+ ): void {
453
+ const overlay = sp.root.querySelector<HTMLElement>('[data-volcano-busy]');
454
+ if (!overlay) return; // already torn down — caller raced detach
455
+ const [labelDiv, detailDiv] = Array.from(overlay.children) as HTMLElement[];
456
+ if (labelDiv) labelDiv.textContent = label;
457
+ if (detailDiv) detailDiv.textContent = detail ?? '';
458
+ }
459
+
460
+ export function hideVolcanoBusy(sp: DG.ScatterPlotViewer): void {
461
+ sp.root.querySelectorAll('[data-volcano-busy]').forEach((el) => el.remove());
462
+ }
463
+
464
+ /** Column that drives the volcano's point labels. Sparse: it holds the chosen
465
+ * display name only for the labeled rows and '' everywhere else. `~`-prefixed so
466
+ * it stays out of the grid. Labels bind to THIS column (showLabelsFor='All')
467
+ * instead of to df.selection, so labeling no longer hijacks the user's selection
468
+ * — that coupling surfaced as the confusing orange "N selected" on open and let
469
+ * the enrichment cross-link wipe the labels. */
470
+ export const VOLCANO_LABEL_COL = '~Volcano label';
471
+ /** Persisted top-N label count so the options dialog + search path can read it. */
472
+ export const VOLCANO_TOPN_TAG = 'proteomics.volcano_top_n';
473
+
474
+ /** Optional pinned axis maxima, stored as df tags so they survive the metric /
475
+ * color toggles that re-enter recomputeVolcano (tag-based state, like the metric
476
+ * and top-N). Empty = auto-scale. X is the symmetric |log2FC| bound (pins
477
+ * xMin=-xMax, xMax=+xMax); Y pins the −log10(p) maximum with yMin at 0. Lets
478
+ * analysts fix identical axes across contrasts for side-by-side comparison
479
+ * (CK x_max_var / y_max_var). */
480
+ export const VOLCANO_XMAX_TAG = 'proteomics.volcano_x_max';
481
+ export const VOLCANO_YMAX_TAG = 'proteomics.volcano_y_max';
482
+
483
+ /** Reads the pinned axis maxima; a tag that is absent, non-numeric, or ≤ 0 reads
484
+ * as null (auto-scale). */
485
+ export function getVolcanoAxisMax(df: DG.DataFrame): {xMax: number | null; yMax: number | null} {
486
+ const parse = (tag: string): number | null => {
487
+ const v = parseFloat(df.getTag(tag) ?? '');
488
+ return Number.isFinite(v) && v > 0 ? v : null;
489
+ };
490
+ return {xMax: parse(VOLCANO_XMAX_TAG), yMax: parse(VOLCANO_YMAX_TAG)};
491
+ }
492
+
493
+ /** Persists the pinned axis maxima; null / non-positive clears the tag (auto). */
494
+ export function setVolcanoAxisMax(df: DG.DataFrame, xMax: number | null, yMax: number | null): void {
495
+ const write = (tag: string, v: number | null) =>
496
+ df.setTag(tag, v != null && Number.isFinite(v) && v > 0 ? String(v) : '');
497
+ write(VOLCANO_XMAX_TAG, xMax);
498
+ write(VOLCANO_YMAX_TAG, yMax);
499
+ }
500
+
501
+ /** Applies the pinned axis bounds from the df tags onto the scatterplot. When a
502
+ * bound is unset, resets that axis to the data's actual min/max (a fit-to-data
503
+ * reset) rather than NaN — the platform rejects a NaN viewport ("Viewport can't
504
+ * be infinite"). Called by createVolcanoPlot and every recomputeVolcano so a
505
+ * metric / color toggle keeps the pinned axes. */
506
+ export function applyVolcanoAxisBounds(sp: DG.ScatterPlotViewer, df: DG.DataFrame): void {
507
+ const {xMax, yMax} = getVolcanoAxisMax(df);
508
+ if (xMax != null) {
509
+ sp.props.xMin = -xMax;
510
+ sp.props.xMax = xMax;
511
+ } else {
512
+ const fc = df.col('log2FC');
513
+ if (fc != null && Number.isFinite(fc.min) && Number.isFinite(fc.max)) {
514
+ const pad = Math.max((fc.max - fc.min) * 0.05, 1e-6);
515
+ sp.props.xMin = fc.min - pad;
516
+ sp.props.xMax = fc.max + pad;
517
+ }
518
+ }
519
+ if (yMax != null) {
520
+ sp.props.yMin = 0;
521
+ sp.props.yMax = yMax;
522
+ } else {
523
+ const y = df.col(NEG_LOG_COL);
524
+ if (y != null && Number.isFinite(y.max)) {
525
+ sp.props.yMin = 0;
526
+ sp.props.yMax = y.max * 1.05 + 1e-6;
527
+ }
528
+ }
529
+ }
530
+
531
+ /** Display Name (Plan 14-01) is canonical; Gene name is the pre-14-01 fallback. */
532
+ function findLabelSource(df: DG.DataFrame): DG.Column | null {
533
+ return findColumn(df, SEMTYPE.DISPLAY_NAME, ['display name']) ??
534
+ findColumn(df, SEMTYPE.GENE_SYMBOL, ['gene name', 'gene symbol']);
535
+ }
536
+
537
+ /** Ranks filtered-in rows by significance (−log10p desc, |log2FC| desc) and
538
+ * returns the top-N row indices. Excludes FLOAT_NULL on either axis. */
539
+ function topNBySignificance(df: DG.DataFrame, n: number): number[] {
540
+ if (n <= 0) return [];
541
+ const yCol = df.col(NEG_LOG_COL);
542
+ const fcCol = df.col('log2FC');
543
+ if (!yCol || !fcCol) return [];
544
+ const yRaw = yCol.getRawData() as Float32Array | Float64Array;
545
+ const fcRaw = fcCol.getRawData() as Float32Array | Float64Array;
546
+
547
+ const candidates: number[] = [];
548
+ for (let i = 0; i < df.rowCount; i++) {
549
+ if (!df.filter.get(i)) continue;
550
+ if (yRaw[i] === DG.FLOAT_NULL || fcRaw[i] === DG.FLOAT_NULL) continue;
551
+ candidates.push(i);
552
+ }
553
+ candidates.sort((a, b) => {
554
+ const dy = yRaw[b] - yRaw[a]; // higher -log10(p) first
555
+ if (dy !== 0) return dy;
556
+ return Math.abs(fcRaw[b]) - Math.abs(fcRaw[a]);
557
+ });
558
+ return candidates.slice(0, n);
559
+ }
560
+
561
+ /** Reads the persisted top-N label count (default 15). */
562
+ export function getVolcanoTopN(df: DG.DataFrame): number {
563
+ const n = parseInt(df.getTag(VOLCANO_TOPN_TAG) ?? '', 10);
564
+ return Number.isFinite(n) && n >= 0 ? n : 15;
565
+ }
566
+
567
+ /** D-03 top-N point labels — decoupled from df.selection (Pitfall: selection-as-
568
+ * labels conflated the orange highlight with the label set and let the
569
+ * enrichment cross-link wipe it). Populates the sparse {@link VOLCANO_LABEL_COL}
570
+ * with the top-N proteins by significance (plus any `extraRows`, e.g. search
571
+ * matches), '' everywhere else, and binds it as the scatter's label source. The
572
+ * user's selection is never touched. `n=0` with no `extraRows` clears labels. */
573
+ export function applyTopNLabels(
574
+ df: DG.DataFrame,
575
+ sp: DG.ScatterPlotViewer,
576
+ n: number = 15,
577
+ extraRows?: Iterable<number>,
578
+ ): void {
579
+ df.setTag(VOLCANO_TOPN_TAG, String(n));
580
+ const src = findLabelSource(df);
581
+ if (!src) return; // nothing to label with → don't add an empty technical column
582
+
583
+ // ensureFreshFloat-style idempotency: reuse the column if present, else add.
584
+ const labelCol = df.col(VOLCANO_LABEL_COL) ?? df.columns.addNewString(VOLCANO_LABEL_COL);
585
+
586
+ const show = new Set<number>(topNBySignificance(df, n));
587
+ if (extraRows) for (const i of extraRows) show.add(i);
588
+
589
+ // Bulk init (memory feedback_dg_column_bulk_init — never per-row set).
590
+ labelCol.init((i) => show.has(i) ? String(src.get(i) ?? '') : '');
591
+
592
+ sp.props.labelColumnNames = [VOLCANO_LABEL_COL];
593
+ sp.props.showLabelsFor = 'All';
594
+ }
595
+
596
+ /** Creates a volcano plot (ScatterPlotViewer). Defaults match the client
597
+ * CK-omics figure: metric = adj.p-value, color = significance (magenta/cyan/
598
+ * gray per D-04). G1 title is synthesized from `proteomics.groups`, D-03
599
+ * top-N labels are seeded into `df.selection`, label binding prefers
600
+ * `Display Name` (Plan 14-01 contract) with `Gene name` as a graceful fallback
601
+ * for DataFrames predating Plan 14-01. */
602
+ export function createVolcanoPlot(
603
+ df: DG.DataFrame,
604
+ options?: {fcThreshold?: number; pThreshold?: number; topNLabels?: number; title?: string},
605
+ ): DG.ScatterPlotViewer {
606
+ const fcThreshold = options?.fcThreshold ?? DEFAULT_FC_THRESHOLD;
607
+ const pThreshold = options?.pThreshold ?? DEFAULT_P_THRESHOLD;
608
+
609
+ // Re-entry contract — dispose every overlay / subscription from a prior
610
+ // createVolcanoPlot call BEFORE attaching new ones (Pitfall 6). We do this
611
+ // again AFTER the new sp is built (with sp passed in) so any orphan DOM on
612
+ // the new sp.root from a previous render is cleared too.
613
+ disposeVolcanoAttachments();
614
+
615
+ const initialMetric: MetricKind = (df.getTag(VOLCANO_METRIC_TAG) as MetricKind | null) ?? 'adj.p-value';
616
+ df.setTag(VOLCANO_METRIC_TAG, initialMetric);
617
+
618
+ const yColName = ensureNegLog10Column(df, initialMetric);
619
+ const colorColName = ensureDirectionColumn(df, fcThreshold, pThreshold, initialMetric);
620
+
621
+ const sp = df.plot.scatter({
622
+ x: 'log2FC',
623
+ y: yColName,
624
+ color: colorColName,
625
+ });
626
+
627
+ applyThresholdLines(df, yColName, fcThreshold, pThreshold);
628
+ sp.props.showViewerFormulaLines = true;
629
+
630
+ // Hide the platform's interactive axis column-selector chips. They render the
631
+ // raw column name ('log2FC' / 'negLog10P') next to each axis and collided with
632
+ // our custom rotated axis labels below (the two overlapping Y titles the fix
633
+ // targets). The `showXSelector`/`showYSelector` props don't suppress them on
634
+ // the current runtime, so we hide the chips with a scoped stylesheet keyed off
635
+ // a marker class; the color/size selectors and canvas ticks are untouched.
636
+ // attachAxisLabels supplies the sole, pretty axis titles.
637
+ ensureVolcanoStyles();
638
+ sp.root.classList.add(VOLCANO_ROOT_CLASS);
639
+
640
+ // G1 title synthesis — runs AFTER any caller-passed options.title so the
641
+ // contract is the synthesized string. Callers that need an explicit title
642
+ // can read the synthesized value back via sp.getOptions().look.title.
643
+ const title = options?.title ?? synthesizeVolcanoTitle(df);
644
+ sp.setOptions({title});
645
+
646
+ attachAxisLabels(sp, df);
647
+ attachCounterOverlay(sp, df);
648
+ applyVolcanoAxisBounds(sp, df);
649
+
650
+ // D-03 (decoupled): label the top-N by significance via the sparse label
651
+ // column — NOT via df.selection. topNLabels=0 opts out (no labels).
652
+ applyTopNLabels(df, sp, options?.topNLabels ?? 15);
653
+
654
+ return sp;
655
+ }
656
+
657
+ /** Single synchronized recompute: re-runs the metric-parameterized Y and
658
+ * direction helpers and the threshold lines together, then points the scatter
659
+ * at the chosen color dimension. After a Q↔P toggle the Y values, up/down/NS
660
+ * classes and the threshold line all move together — they never disagree.
661
+ * `colorDim`: 'significance' → direction column, 'location' → the locked
662
+ * Subcellular-Location palette. `metric` 'p-value' is guarded (falls back to
663
+ * adj.p-value when the column is absent). */
664
+ export async function recomputeVolcano(
665
+ df: DG.DataFrame,
666
+ sp: DG.ScatterPlotViewer,
667
+ metric: MetricKind,
668
+ colorDim: ColorDim,
669
+ fcThreshold = DEFAULT_FC_THRESHOLD,
670
+ pThreshold = DEFAULT_P_THRESHOLD,
671
+ progress?: ProgressCb,
672
+ ): Promise<void> {
673
+ // Persist BEFORE the column-recompute path so the axis-label refresh below
674
+ // sees the new metric. The tag is the single source of truth for G2.
675
+ df.setTag(VOLCANO_METRIC_TAG, metric);
676
+
677
+ const yName = ensureNegLog10Column(df, metric);
678
+ const dirName = ensureDirectionColumn(df, fcThreshold, pThreshold, metric);
679
+ applyThresholdLines(df, yName, fcThreshold, pThreshold);
680
+
681
+ sp.props.xColumnName = 'log2FC';
682
+ sp.props.yColumnName = yName;
683
+ if (colorDim === 'location')
684
+ sp.props.colorColumnName = await ensureLocationColumn(df, progress);
685
+ else
686
+ sp.props.colorColumnName = dirName;
687
+
688
+ refreshVolcanoAxisLabel(sp, df);
689
+ applyVolcanoAxisBounds(sp, df);
690
+ }