@sjcrh/proteinpaint-server 2.207.1 → 2.209.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.
@@ -426,6 +426,11 @@ function termdb_test_default() {
426
426
  {
427
427
  index: 3,
428
428
  name: "Cell Type"
429
+ },
430
+ {
431
+ index: 4,
432
+ name: "Cell Cycle Score",
433
+ type: "numeric"
429
434
  }
430
435
  ],
431
436
  coordsColumns: { x: 1, y: 2 },
@@ -69,6 +69,28 @@ function getHg38() {
69
69
  stackspace: 1,
70
70
  vpad: 4
71
71
  },
72
+ {
73
+ /* ENCODE cCRE registry, coloured by class in SCREEN's own colours. Declared here rather
74
+ than in a dataset so any hg38 block can turn it on from the Tracks menu; like
75
+ RepeatMasker it is available, not default-on, because __isgene is what
76
+ first_genetrack_tolist adds automatically. The differential-methylation region view
77
+ switches it on explicitly, where a cCRE is at a scale it can actually be drawn at.
78
+ Built by utils/dnaMeth/build_ccre_track.py -- the shipped registry carries the class in
79
+ column 6, where bedj cannot see it. */
80
+ type: "bedj",
81
+ name: "ENCODE cCREs",
82
+ file: "anno/encodeCCREtrack.hg38.gz",
83
+ stackheight: 12,
84
+ stackspace: 1,
85
+ vpad: 3,
86
+ categories: {
87
+ PLS: { color: "#FF0000", label: "Promoter-like" },
88
+ pELS: { color: "#FFA700", label: "Proximal enhancer-like" },
89
+ dELS: { color: "#FFCD00", label: "Distal enhancer-like" },
90
+ "CTCF-only": { color: "#00B0F0", label: "CTCF-only" },
91
+ "DNase-H3K4me3": { color: "#FFAAAA", label: "DNase-H3K4me3" }
92
+ }
93
+ },
72
94
  {
73
95
  type: "bedj",
74
96
  name: "RepeatMasker",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjcrh/proteinpaint-server",
3
- "version": "2.207.1",
3
+ "version": "2.209.0",
4
4
  "type": "module",
5
5
  "description": "a genomics visualization tool for exploring a cohort's genotype and phenotype data",
6
6
  "main": "src/app.js",
@@ -28,8 +28,8 @@
28
28
  "mjs": "esbuild \"$DIR/*.ts\" --platform=node --outdir=\"$DIR\" --format=esm",
29
29
  "cjs": "esbuild \"$DIR/*.ts\" --platform=node --outdir=\"$DIR\" --format=cjs",
30
30
  "build": "./build.sh",
31
- "prepack": "npm run build",
32
- "postpack": "./dedupjs.sh",
31
+ "prepack": "npm run build && npm pkg set _buildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)",
32
+ "postpack": "npm pkg delete _buildTime && ./dedupjs.sh",
33
33
  "dedup": "./dedupjs.sh"
34
34
  },
35
35
  "author": "",
@@ -57,11 +57,11 @@
57
57
  },
58
58
  "dependencies": {
59
59
  "@sjcrh/augen": "2.204.0",
60
- "@sjcrh/proteinpaint-python": "2.207.0",
60
+ "@sjcrh/proteinpaint-python": "2.209.0",
61
61
  "@sjcrh/proteinpaint-r": "2.207.1",
62
- "@sjcrh/proteinpaint-rust": "2.207.0",
63
- "@sjcrh/proteinpaint-shared": "2.207.1",
64
- "@sjcrh/proteinpaint-types": "2.207.1",
62
+ "@sjcrh/proteinpaint-rust": "2.209.0",
63
+ "@sjcrh/proteinpaint-shared": "2.209.0",
64
+ "@sjcrh/proteinpaint-types": "2.209.0",
65
65
  "@types/express": "^5.0.0",
66
66
  "@types/express-session": "^1.18.1",
67
67
  "better-sqlite3": "^12.4.1",
@@ -120,5 +120,6 @@
120
120
  }
121
121
  ]
122
122
  ]
123
- }
123
+ },
124
+ "_buildTime": "2026-09-16T15:51:09Z"
124
125
  }
@@ -1,7 +1,13 @@
1
+ import { DMR_SCAN_ELEMENT_TYPE } from "#types";
2
+ import { runDmrBatch } from "#src/routes/termdb.dmrBatch.ts";
3
+ import { dmrScanToRows, summarizeProfile, coarsenProfile } from "#src/utils/dmrScanRows.ts";
4
+ import { resolveGroupNames, matchedSamplelst, eligibleMethylationSamples } from "#src/utils/methylationMatrix.ts";
1
5
  import { mayLog } from "#src/helpers.ts";
2
6
  import { run_R } from "@sjcrh/proteinpaint-r";
3
7
  import { formatElapsedTime } from "#shared";
4
8
  import { renderVolcano } from "../src/renderVolcano.ts";
9
+ import { renderManhattanPoints } from "../src/renderManhattan.ts";
10
+ import { HYPER_COLOR, HYPO_COLOR } from "#shared/dmrColors.js";
5
11
  import { cacheOrRecompute } from "#src/utils/cacheOrRecompute.ts";
6
12
  import {
7
13
  buildGroupValues,
@@ -30,12 +36,37 @@ function init({ genomes }) {
30
36
  const rendered = await renderVolcano(result.promoterRows, q.volcanoRender);
31
37
  rendered.cacheId = cacheId;
32
38
  if (rendered.totalRows === 0)
33
- throw new Error("No promoters passed filtering. Try relaxing group criteria or selecting more samples.");
39
+ throw new Error(
40
+ !result.scan ? "No promoters passed filtering. Try relaxing group criteria or selecting more samples." : result.scan.kept && result.scan.backgroundCorrection ? (
41
+ /* rows omit unscored DMRs under the correction, so none plotted does not mean
42
+ none met the floor */
43
+ `None of the ${result.scan.kept.toLocaleString()} DMRs meeting the CpG floor could be scored against matched background. Turn off the background correction to see them.`
44
+ ) : "The scan called no DMRs that met the CpG floor."
45
+ );
34
46
  const output = {
35
47
  data: rendered,
36
48
  sample_size1: result.sample_size1,
37
49
  sample_size2: result.sample_size2
38
50
  };
51
+ if (result.scan) {
52
+ output.scan = result.scan;
53
+ if (result.scan.binMethylation) {
54
+ output.scan.profileSummary = summarizeProfile(result.scan.binMethylation);
55
+ output.scan.profile = await renderMethylationProfile(
56
+ result.scan,
57
+ genomes[q.genome],
58
+ q.volcanoRender?.devicePixelRatio,
59
+ // display width only; the summary rows above stay on the native 100 kb bin
60
+ q.scan?.profileBinBp
61
+ );
62
+ delete output.scan.binMethylation;
63
+ }
64
+ output.scan.manhattan = await renderScanManhattan(
65
+ result.promoterRows,
66
+ genomes[q.genome],
67
+ q.volcanoRender?.devicePixelRatio
68
+ );
69
+ }
39
70
  res.send(output);
40
71
  } catch (e) {
41
72
  res.status(e.status || 500).send({ status: "error", error: e.message || e, code: e.code });
@@ -95,6 +126,7 @@ function dmKeyInputs(req, imputeMissing) {
95
126
  };
96
127
  }
97
128
  async function getDmCacheResult(req, genomes) {
129
+ if (req.element_type === DMR_SCAN_ELEMENT_TYPE) return getDmrScanAsDm(req, genomes);
98
130
  const imputeMissing = genomes?.[req.genome]?.datasets?.[req.dslabel]?.queries?.dnaMethylation?.platform != "wgbs";
99
131
  const { result, cacheId } = await cacheOrRecompute({
100
132
  computeArgument: dmKeyInputs(req, imputeMissing),
@@ -106,6 +138,146 @@ async function getDmCacheResult(req, genomes) {
106
138
  });
107
139
  return { result, cacheId };
108
140
  }
141
+ function scanChromosomes(req, genome) {
142
+ let chromosomes = req.scan?.chromosome ? [req.scan.chromosome] : genome.majorchrorder.filter((c) => c != "chrM" && c != "chrMT");
143
+ if (req.exclude_sex_chr) chromosomes = chromosomes.filter((c) => !/^chr[XY]$/i.test(c));
144
+ if (!chromosomes.length) throw new Error("No chromosomes left to scan.");
145
+ return chromosomes;
146
+ }
147
+ async function getDmrScanAsDm(req, genomes) {
148
+ if (req.tw || req.tw2) throw new Error("Confounding factors are not supported by the DMR scan.");
149
+ const genome = genomes[req.genome];
150
+ if (!genome) throw new Error("unknown genome");
151
+ const ds = genome.datasets?.[req.dslabel];
152
+ if (!ds) throw new Error("unknown dataset");
153
+ const groups = req.samplelst?.groups;
154
+ if (groups?.length != 2)
155
+ throw new Error("Exactly 2 sample groups are required for differential methylation analysis.");
156
+ const chromosomes = scanChromosomes(req, genome);
157
+ const { payload, cacheId } = await runDmrBatch(
158
+ {
159
+ genome: req.genome,
160
+ dslabel: req.dslabel,
161
+ group1: groups[0].values,
162
+ group2: groups[1].values,
163
+ scanChromosomes: chromosomes,
164
+ backgroundCorrection: !!req.scan?.backgroundCorrection,
165
+ // the genome-wide profile: the metric the methylome literature compares cohorts with
166
+ binMethylation: true,
167
+ lambda: positiveOrUndefined(req.scan?.lambda),
168
+ C: positiveOrUndefined(req.scan?.C),
169
+ fdr_cutoff: positiveOrUndefined(req.scan?.fdrCutoff)
170
+ },
171
+ genomes
172
+ );
173
+ const eligible = eligibleMethylationSamples(ds, void 0);
174
+ const { group1, group2 } = await resolveGroupNames(groups[0].values, groups[1].values, eligible, ds);
175
+ const { rows, scan } = dmrScanToRows(payload, {
176
+ chromosomes,
177
+ minCpgs: req.scan?.minCpgs,
178
+ backgroundCorrection: !!req.scan?.backgroundCorrection
179
+ });
180
+ scan.matchedSamplelst = await matchedSamplelst(req.samplelst, eligible, ds);
181
+ scan.cacheId = cacheId;
182
+ return {
183
+ result: { promoterRows: rows, sample_size1: group1.length, sample_size2: group2.length, scan },
184
+ cacheId
185
+ };
186
+ }
187
+ function positiveOrUndefined(v) {
188
+ const n = Number(v);
189
+ return v != null && Number.isFinite(n) && n > 0 ? n : void 0;
190
+ }
191
+ const SCAN_INTERACTIVE_PER_SIDE = 1e3;
192
+ async function renderScanManhattan(rows, genome, devicePixelRatio) {
193
+ const chrSizes = {};
194
+ for (const c of genome.majorchrorder) if (c != "chrM" && c != "chrMT") chrSizes[c] = genome.majorchr[c];
195
+ const points = rows.map((r) => {
196
+ const p = r.original_p_value;
197
+ const mag = p > 0 ? -Math.log10(p) : Infinity;
198
+ return {
199
+ chrom: r.chr,
200
+ pos: r.start,
201
+ y: r.delta_beta < 0 ? -mag : mag,
202
+ color: r.delta_beta < 0 ? HYPO_COLOR : HYPER_COLOR,
203
+ start: r.start,
204
+ stop: r.stop,
205
+ delta_beta: r.delta_beta,
206
+ fold_change: r.fold_change,
207
+ p,
208
+ no_cpgs: r.no_cpgs,
209
+ gene_name: r.gene_name,
210
+ ...r.excess != null ? { excess: r.excess } : {}
211
+ };
212
+ });
213
+ const plotWidth = 1e3;
214
+ const plotHeight = 300;
215
+ const { png, plot_data } = await renderManhattanPoints({
216
+ points,
217
+ chrSizes,
218
+ plotWidth,
219
+ plotHeight,
220
+ devicePixelRatio: devicePixelRatio || 1,
221
+ pngDotRadius: 2,
222
+ // the GRIN2 defaults: raise the cap only when more than 5 dots would sit on it
223
+ maxCappedPoints: 5,
224
+ hardCap: 200,
225
+ binSize: 10,
226
+ interactive: SCAN_INTERACTIVE_PER_SIDE,
227
+ signed: true,
228
+ // open circles, as the volcano above draws the same DMRs
229
+ hollow: true
230
+ });
231
+ return { png, plotData: plot_data, interactive: SCAN_INTERACTIVE_PER_SIDE, plotWidth, plotHeight };
232
+ }
233
+ function renderMethylationProfile(scan, genome, devicePixelRatio, profileBinBp) {
234
+ if (!scan.binMethylation?.bins.length) return void 0;
235
+ const bm = coarsenProfile(scan.binMethylation, profileBinBp);
236
+ const chrSizes = {};
237
+ for (const c of genome.majorchrorder) if (c != "chrM" && c != "chrMT") chrSizes[c] = genome.majorchr[c];
238
+ const points = bm.bins.map((b) => {
239
+ const d = b.case - b.control;
240
+ return {
241
+ chrom: b.chr,
242
+ pos: b.start,
243
+ y: d,
244
+ color: d < 0 ? HYPO_COLOR : HYPER_COLOR,
245
+ control: b.control,
246
+ case: b.case,
247
+ n_probes: b.n_probes
248
+ };
249
+ });
250
+ const plotWidth = 1e3;
251
+ const plotHeight = 160;
252
+ const dotRadius = 1;
253
+ return renderManhattanPoints({
254
+ points,
255
+ chrSizes,
256
+ plotWidth,
257
+ plotHeight,
258
+ devicePixelRatio: devicePixelRatio || 1,
259
+ pngDotRadius: dotRadius,
260
+ maxCappedPoints: 5,
261
+ hardCap: 200,
262
+ binSize: 10,
263
+ /* The bins that moved most, each way. Ranking is |y| = |Δβ| here rather than evidence: on
264
+ this figure the effect size IS the result, and the bins a reader wants to identify are the
265
+ excursions away from the zero line. */
266
+ interactive: SCAN_INTERACTIVE_PER_SIDE,
267
+ signed: true,
268
+ capping: false
269
+ }).then(({ png, plot_data }) => ({
270
+ png,
271
+ plotData: plot_data,
272
+ binBp: bm.binBp,
273
+ plotWidth,
274
+ plotHeight,
275
+ dotRadius,
276
+ bins: bm.bins.length,
277
+ // what the top-N-per-side rule actually left live, which at a coarse width is every bin
278
+ interactive: plot_data.points.length
279
+ }));
280
+ }
109
281
  async function runDmFresh(param, ds, term_results, term_results2, imputeMissing) {
110
282
  const groups = await resolveDmSampleGroups(param, ds, term_results, term_results2);
111
283
  if (groups.alerts.length) throw new Error(groups.alerts.join(" | "));
@@ -150,10 +322,10 @@ async function resolveDmSampleGroups(param, ds, term_results, term_results2) {
150
322
  throw new Error("Group 1 has no samples. Please select at least one sample.");
151
323
  if (param.samplelst.groups[1].values?.length < 1)
152
324
  throw new Error("Group 2 has no samples. Please select at least one sample.");
153
- const { q } = resolveElementQuery(ds, param.element_type);
325
+ const allSampleSet = eligibleMethylationSamples(ds, param.element_type);
154
326
  const g1 = await buildGroupValues(
155
327
  param.samplelst.groups[0].values,
156
- q.allSampleSet,
328
+ allSampleSet,
157
329
  ds,
158
330
  param.tw,
159
331
  param.tw2,
@@ -162,7 +334,7 @@ async function resolveDmSampleGroups(param, ds, term_results, term_results2) {
162
334
  );
163
335
  const g2 = await buildGroupValues(
164
336
  param.samplelst.groups[1].values,
165
- q.allSampleSet,
337
+ allSampleSet,
166
338
  ds,
167
339
  param.tw,
168
340
  param.tw2,
@@ -192,5 +364,6 @@ export {
192
364
  init,
193
365
  listElementTypes,
194
366
  resolveDmSampleGroups,
195
- resolveElementQuery
367
+ resolveElementQuery,
368
+ scanChromosomes
196
369
  };