@sjcrh/proteinpaint-server 2.203.0 → 2.204.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.
@@ -412,8 +412,8 @@ function termdb_test_default() {
412
412
  },
413
413
  singleCell: {
414
414
  samples: {
415
- sampleColumns: [{ termid: "sex" }],
416
- extraSampleTabLabel: "sex"
415
+ sampleColumns: [{ termid: "diaggrp" }],
416
+ extraSampleTabLabel: "diaggrp"
417
417
  },
418
418
  data: {
419
419
  sameLegend: true,
@@ -441,10 +441,22 @@ function termdb_test_default() {
441
441
  type: "H&E",
442
442
  imageBySampleFolder: "files/hg38/TermdbTest/wsimages"
443
443
  },
444
- // w2 plot: each sample with images is a subfolder of this directory,
445
- // holding that sample's .svs files (folder/<sample>/<fileName>)
444
+ // w2 plot. folder: spatial images, one per subfolder of the sample's
445
+ // directory (folder/<sample>/<imageName>/), files inside found by the
446
+ // *FileSuffix fields. wsiFolder: plain whole-slide images, laid out as
447
+ // wsiFolder/<sample>/<imageName>/<slide file>
446
448
  w2: {
447
- folder: "files/hg38/TermdbTest/wsimages"
449
+ folder: "files/hg38/TermdbTest/spatial",
450
+ wsiFolder: "files/hg38/TermdbTest/wsimages",
451
+ // .ome.tif extension is required for the JPEG-2000 OME-TIFF reader
452
+ // in wsi_tile.py open_slide() to engage
453
+ tiffFileSuffix: "morphology.ome.tif",
454
+ nucleusBoundariesFileSuffix: "nucleus_boundaries.csv",
455
+ cellBoundariesFileSuffix: "cell_boundaries.csv",
456
+ geneExpressionFileSuffix: "gene_expression.h5",
457
+ // default viewer settings, overridable in the burger menu
458
+ geneExpression: "PTPRC",
459
+ annotationLevel: 1
448
460
  },
449
461
  trackLst: {
450
462
  jsonFile: "files/hg38/TermdbTest/trackLst/facet.json",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjcrh/proteinpaint-server",
3
- "version": "2.203.0",
3
+ "version": "2.204.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",
@@ -46,9 +46,8 @@
46
46
  "@typescript-eslint/eslint-plugin": "^8.13.0",
47
47
  "babel-loader": "^8.2.2",
48
48
  "c8": "^10.1.3",
49
- "esbuild": "^0.25.9",
49
+ "esbuild": "^0.28.1",
50
50
  "monocart-coverage-reports": "^2.12.1",
51
- "node-notifier": "^9.0.1",
52
51
  "node-watch": "^0.7.1",
53
52
  "nodemon": "^3.1.10",
54
53
  "prettier": "^2.8.8",
@@ -57,12 +56,12 @@
57
56
  "typescript": "^5.6.3"
58
57
  },
59
58
  "dependencies": {
60
- "@sjcrh/augen": "2.190.0",
61
- "@sjcrh/proteinpaint-python": "2.203.0",
62
- "@sjcrh/proteinpaint-r": "2.203.0",
63
- "@sjcrh/proteinpaint-rust": "2.203.0",
64
- "@sjcrh/proteinpaint-shared": "2.203.0",
65
- "@sjcrh/proteinpaint-types": "2.203.0",
59
+ "@sjcrh/augen": "2.204.0",
60
+ "@sjcrh/proteinpaint-python": "2.204.0",
61
+ "@sjcrh/proteinpaint-r": "2.204.0",
62
+ "@sjcrh/proteinpaint-rust": "2.204.0",
63
+ "@sjcrh/proteinpaint-shared": "2.204.0",
64
+ "@sjcrh/proteinpaint-types": "2.204.0",
66
65
  "@types/express": "^5.0.0",
67
66
  "@types/express-session": "^1.18.1",
68
67
  "better-sqlite3": "^12.4.1",
@@ -1,6 +1,8 @@
1
+ import fs from "fs";
1
2
  import path from "path";
2
3
  import serverconfig from "#src/serverconfig.js";
3
4
  import { getData } from "../src/termdb.matrix.js";
5
+ import { filterSampleNamesByAccess } from "#src/termdb.sql.js";
4
6
  import { isNumericTerm } from "#shared";
5
7
  import { getColors } from "#shared";
6
8
  import { run_python } from "@sjcrh/proteinpaint-python";
@@ -13,10 +15,10 @@ function init({ genomes }) {
13
15
  const ds = g.datasets[query.dslabel];
14
16
  if (!ds) throw "invalid dataset name";
15
17
  let plane, index;
16
- if (query.l) {
18
+ if (query.l != void 0) {
17
19
  plane = "L";
18
20
  index = query.l;
19
- } else if (query.f) {
21
+ } else if (query.f != void 0) {
20
22
  plane = "F";
21
23
  index = query.f;
22
24
  } else {
@@ -27,7 +29,7 @@ function init({ genomes }) {
27
29
  res.send({ brainImage, plane, legend });
28
30
  } catch (e) {
29
31
  console.log(e);
30
- res.status(404).send("Sample brain image not found");
32
+ res.status(404).send(typeof e == "string" ? e : "Sample brain image not found");
31
33
  }
32
34
  };
33
35
  }
@@ -43,8 +45,14 @@ async function getBrainImage(query, genomes, plane, index) {
43
45
  const overlayTW = query.overlayTW;
44
46
  if (divideByTW) terms.push(divideByTW);
45
47
  if (overlayTW) terms.push(overlayTW);
46
- const selectedSampleNames = query.selectedSampleFileNames.map((s) => s.split(".nii")[0]);
47
- const data = await getData({ terms }, ds);
48
+ let selectedSampleNames = query.selectedSampleFileNames.map((s) => s.split(".nii")[0]);
49
+ const existingFiles = new Set(await fs.promises.readdir(dirPath));
50
+ selectedSampleNames = selectedSampleNames.filter((s) => existingFiles.has(s + ".nii"));
51
+ if (!selectedSampleNames.length) throw "no brain imaging data for the requested sample(s)";
52
+ selectedSampleNames = await filterSampleNamesByAccess(query, ds, selectedSampleNames);
53
+ if (!selectedSampleNames.length) throw "no selected samples pass the current filter";
54
+ const data = await getData({ terms, __protected__: query.__protected__ }, ds);
55
+ if (data.error) throw data.error;
48
56
  const divideByCat = {};
49
57
  const uniqueOverlayTwCats = /* @__PURE__ */ new Set();
50
58
  for (const sampleName of selectedSampleNames) {
@@ -53,11 +61,11 @@ async function getBrainImage(query, genomes, plane, index) {
53
61
  const samplePath = path.join(dirPath, sampleName) + ".nii";
54
62
  let divideCategory = "default";
55
63
  let overlayCategory = "default";
56
- if (divideByTW) {
64
+ if (divideByTW && sampleData) {
57
65
  const value = sampleData[divideByTW.$id];
58
66
  if (value) divideCategory = divideByTW.term.values?.[value.key]?.label || value.key;
59
67
  }
60
- if (overlayTW) {
68
+ if (overlayTW && sampleData) {
61
69
  const value = sampleData[overlayTW.$id];
62
70
  if (value) {
63
71
  overlayCategory = overlayTW.term.values?.[value.key]?.label || value.key;
@@ -2,6 +2,7 @@ import fs from "fs";
2
2
  import path from "path";
3
3
  import serverconfig from "#src/serverconfig.js";
4
4
  import { getData } from "#src/termdb.matrix.js";
5
+ import { filterSampleNamesByAccess } from "#src/termdb.sql.js";
5
6
  function init({ genomes }) {
6
7
  return async (req, res) => {
7
8
  try {
@@ -14,7 +15,7 @@ function init({ genomes }) {
14
15
  res.send({ samples });
15
16
  } catch (e) {
16
17
  console.log(e);
17
- res.status(404).send("Sample brain image not found");
18
+ res.status(404).send(typeof e == "string" ? e : "Cannot get brain imaging samples");
18
19
  }
19
20
  };
20
21
  }
@@ -24,15 +25,20 @@ async function getBrainImageSamples(query, genomes) {
24
25
  const key = query.refKey;
25
26
  if (q[key].referenceFile && q[key].samples) {
26
27
  const dirPath = path.join(serverconfig.tpmasterdir, q[key].samples);
27
- const files = fs.readdirSync(dirPath).filter((file) => file.endsWith(".nii") && fs.statSync(path.join(dirPath, file)).isFile());
28
- const sampleNames = files.map((name) => name.split(".nii")[0]);
28
+ const files = (await fs.promises.readdir(dirPath, { withFileTypes: true })).filter((f) => f.isFile() && f.name.endsWith(".nii")).map((f) => f.name);
29
+ let sampleNames = files.map((name) => name.split(".nii")[0]);
30
+ sampleNames = await filterSampleNamesByAccess(query, ds, sampleNames);
31
+ if (query.samplesOnly) return sampleNames.map((name) => ({ sample: name }));
29
32
  if (q[key].sampleColumns) {
30
- const terms = q[key].sampleColumns.map((term) => ({
31
- $id: term.termid,
32
- term: { id: term.termid },
33
- q: {}
34
- }));
35
- const data = await getData({ terms }, ds);
33
+ const terms = q[key].sampleColumns.map((term) => {
34
+ const termjson = ds.cohort.termdb.q.termjsonByOneid(term.termid);
35
+ return {
36
+ $id: term.termid,
37
+ term: termjson,
38
+ q: termjson?.type == "float" || termjson?.type == "integer" ? { mode: "continuous" } : {}
39
+ };
40
+ });
41
+ const data = await getData({ terms, __protected__: query.__protected__ }, ds);
36
42
  if (data.error) throw data.error;
37
43
  const samples = {};
38
44
  for (const s of sampleNames) {
@@ -43,12 +43,51 @@ function init({ genomes }) {
43
43
  }
44
44
  };
45
45
  }
46
- function dmKeyInputs(req) {
46
+ function resolveElementQuery(ds, elementType) {
47
+ const key = elementType ?? "promoter";
48
+ const dm = ds?.queries?.dnaMethylation;
49
+ if (!dm) throw new Error("This dataset does not have methylation data configured.");
50
+ const q = dm.elements?.[key] ?? (key === "promoter" ? dm.promoter : void 0);
51
+ if (!q) {
52
+ const available = [
53
+ ...Object.keys(dm.elements ?? {}),
54
+ ...dm.promoter && !dm.elements?.promoter ? ["promoter"] : []
55
+ ];
56
+ throw new Error(
57
+ available.length ? `Unknown element type '${key}'. This dataset offers: ${available.join(", ")}.` : "This dataset does not have element-level methylation data configured."
58
+ );
59
+ }
60
+ if (!q.file) throw new Error(`Methylation matrix file is not configured for element type '${key}'.`);
61
+ return { key, q };
62
+ }
63
+ function listElementTypes(ds) {
64
+ const dm = ds?.queries?.dnaMethylation;
65
+ if (!dm) return [];
66
+ const out = [];
67
+ for (const [key, e] of Object.entries(dm.elements ?? {})) {
68
+ if (e?.file) out.push({ key, label: e.label || key });
69
+ }
70
+ if (dm.promoter?.file && !dm.elements?.promoter) {
71
+ out.unshift({ key: "promoter", label: dm.promoter.label || "Promoters" });
72
+ }
73
+ return out;
74
+ }
75
+ function dmKeyInputs(req, imputeMissing) {
47
76
  return {
48
77
  genome: req.genome,
49
78
  dslabel: req.dslabel,
79
+ /* Which element matrix was tested. Without this field a block request with the
80
+ same sample groups as an earlier promoter request hashes to the same key and is
81
+ served the promoter result — wrong rows, wrong coordinates, no error. Defaulting
82
+ to 'promoter' rather than null keeps pre-existing cache entries valid on deploy. */
83
+ element_type: req.element_type ?? "promoter",
84
+ /* Derived from ds config rather than sent by the client, but it still belongs in the
85
+ key: flipping a dataset's platform changes every p-value, and without it the on-disk
86
+ cache would keep serving results computed under the old missingness model. */
87
+ impute_missing: imputeMissing,
50
88
  samplelst: canonicalizeSamplelst(req.samplelst),
51
89
  min_samples_per_group: req.min_samples_per_group ?? null,
90
+ exclude_sex_chr: req.exclude_sex_chr ?? null,
52
91
  tw: req.tw ?? null,
53
92
  tw2: req.tw2 ?? null,
54
93
  filter: req.filter ?? null,
@@ -56,26 +95,33 @@ function dmKeyInputs(req) {
56
95
  };
57
96
  }
58
97
  async function getDmCacheResult(req, genomes) {
98
+ const imputeMissing = genomes?.[req.genome]?.datasets?.[req.dslabel]?.queries?.dnaMethylation?.platform != "wgbs";
59
99
  const { result, cacheId } = await cacheOrRecompute({
60
- computeArgument: dmKeyInputs(req),
100
+ computeArgument: dmKeyInputs(req, imputeMissing),
61
101
  cacheSubdir: "dm",
62
102
  computeFresh: async () => {
63
103
  const { ds, term_results, term_results2 } = await resolveDaContext(req, genomes);
64
- return runDmFresh(req, ds, term_results, term_results2);
104
+ return runDmFresh(req, ds, term_results, term_results2, imputeMissing);
65
105
  }
66
106
  });
67
107
  return { result, cacheId };
68
108
  }
69
- async function runDmFresh(param, ds, term_results, term_results2) {
109
+ async function runDmFresh(param, ds, term_results, term_results2, imputeMissing) {
70
110
  const groups = await resolveDmSampleGroups(param, ds, term_results, term_results2);
71
111
  if (groups.alerts.length) throw new Error(groups.alerts.join(" | "));
72
- const q = ds.queries.dnaMethylation.promoter;
112
+ const { q } = resolveElementQuery(ds, param.element_type);
73
113
  const diffMethInput = {
74
114
  // Group 1 is control, group 2 is case (same convention as DE).
75
115
  case: groups.group2names.join(","),
76
116
  control: groups.group1names.join(","),
77
117
  input_file: q.file,
78
- min_samples_per_group: param.min_samples_per_group
118
+ // Omitted entirely when the entry does not set it, so an unfiltered request stays
119
+ // byte-identical to what it was before this field existed.
120
+ ...q.element_class ? { element_class: q.element_class } : {},
121
+ min_samples_per_group: param.min_samples_per_group,
122
+ exclude_sex_chr: param.exclude_sex_chr,
123
+ // ds-derived, not a user setting: see the platform field on queries.dnaMethylation
124
+ impute_missing: imputeMissing
79
125
  };
80
126
  if (param.tw) {
81
127
  diffMethInput.conf1 = [...groups.conf1_group2, ...groups.conf1_group1];
@@ -104,12 +150,10 @@ async function resolveDmSampleGroups(param, ds, term_results, term_results2) {
104
150
  throw new Error("Group 1 has no samples. Please select at least one sample.");
105
151
  if (param.samplelst.groups[1].values?.length < 1)
106
152
  throw new Error("Group 2 has no samples. Please select at least one sample.");
107
- const q = ds.queries.dnaMethylation?.promoter;
108
- if (!q) throw new Error("This dataset does not have promoter-level methylation data configured.");
109
- if (!q.file) throw new Error("Promoter methylation data file is not configured for this dataset.");
153
+ const { q } = resolveElementQuery(ds, param.element_type);
110
154
  const g1 = await buildGroupValues(
111
155
  param.samplelst.groups[0].values,
112
- q,
156
+ q.allSampleSet,
113
157
  ds,
114
158
  param.tw,
115
159
  param.tw2,
@@ -118,7 +162,7 @@ async function resolveDmSampleGroups(param, ds, term_results, term_results2) {
118
162
  );
119
163
  const g2 = await buildGroupValues(
120
164
  param.samplelst.groups[1].values,
121
- q,
165
+ q.allSampleSet,
122
166
  ds,
123
167
  param.tw,
124
168
  param.tw2,
@@ -146,5 +190,7 @@ async function resolveDmSampleGroups(param, ds, term_results, term_results2) {
146
190
  export {
147
191
  getDmCacheResult,
148
192
  init,
149
- resolveDmSampleGroups
193
+ listElementTypes,
194
+ resolveDmSampleGroups,
195
+ resolveElementQuery
150
196
  };