@datagrok/proteomics 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 -62
- 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,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
|
+
}
|