@sjcrh/proteinpaint-server 2.184.0 → 2.185.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/dataset/protected.test.js +5 -0
- package/dataset/termdb.test.js +1 -1
- package/package.json +5 -5
- package/routes/brainImagingSamples.js +15 -4
- package/routes/genesetEnrichment.js +101 -42
- package/routes/profile.radar2.js +112 -0
- package/routes/profile.radarFacility2.js +148 -0
- package/routes/saveWSIAnnotation.js +21 -0
- package/routes/termdb.DE.js +31 -238
- package/routes/termdb.chat3.js +191 -0
- package/routes/termdb.cluster.js +44 -9
- package/routes/termdb.config.js +5 -3
- package/routes/termdb.diffMeth.js +4 -2
- package/routes/termdb.proteome.js +28 -20
- package/routes/termdb.singlecellDEgenes.js +2 -1
- package/routes/termdb.singlecellSamples.js +36 -5
- package/src/app.js +3517 -2542
- package/src/serverconfig.js +16 -1
package/dataset/termdb.test.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sjcrh/proteinpaint-server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.185.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",
|
|
@@ -62,11 +62,11 @@
|
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
64
|
"@sjcrh/augen": "2.181.1",
|
|
65
|
-
"@sjcrh/proteinpaint-python": "2.
|
|
65
|
+
"@sjcrh/proteinpaint-python": "2.185.0",
|
|
66
66
|
"@sjcrh/proteinpaint-r": "2.181.0",
|
|
67
|
-
"@sjcrh/proteinpaint-rust": "2.
|
|
68
|
-
"@sjcrh/proteinpaint-shared": "2.
|
|
69
|
-
"@sjcrh/proteinpaint-types": "2.
|
|
67
|
+
"@sjcrh/proteinpaint-rust": "2.185.0",
|
|
68
|
+
"@sjcrh/proteinpaint-shared": "2.185.0",
|
|
69
|
+
"@sjcrh/proteinpaint-types": "2.185.0",
|
|
70
70
|
"@types/express": "^5.0.0",
|
|
71
71
|
"@types/express-session": "^1.18.1",
|
|
72
72
|
"better-sqlite3": "^12.4.1",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import serverconfig from "#src/serverconfig.js";
|
|
4
|
+
import { getData } from "#src/termdb.matrix.js";
|
|
4
5
|
const api = {
|
|
5
6
|
endpoint: "brainImagingSamples",
|
|
6
7
|
methods: {
|
|
@@ -40,14 +41,24 @@ async function getBrainImageSamples(query, genomes) {
|
|
|
40
41
|
const files = fs.readdirSync(dirPath).filter((file) => file.endsWith(".nii") && fs.statSync(path.join(dirPath, file)).isFile());
|
|
41
42
|
const sampleNames = files.map((name) => name.split(".nii")[0]);
|
|
42
43
|
if (q[key].sampleColumns) {
|
|
44
|
+
const terms = q[key].sampleColumns.map((term) => ({
|
|
45
|
+
$id: term.termid,
|
|
46
|
+
term: { id: term.termid },
|
|
47
|
+
q: {}
|
|
48
|
+
}));
|
|
49
|
+
const data = await getData({ terms }, ds);
|
|
50
|
+
if (data.error) throw data.error;
|
|
43
51
|
const samples = {};
|
|
44
52
|
for (const s of sampleNames) {
|
|
45
53
|
const annoForOneS = { sample: s };
|
|
46
54
|
const sid = ds.cohort.termdb.q.sampleName2id(s);
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
55
|
+
const sampleData = data.samples?.[sid];
|
|
56
|
+
if (sampleData) {
|
|
57
|
+
for (const term of q[key].sampleColumns) {
|
|
58
|
+
const v = sampleData[term.termid];
|
|
59
|
+
if (v?.value !== void 0) {
|
|
60
|
+
annoForOneS[term.termid] = v.value;
|
|
61
|
+
}
|
|
51
62
|
}
|
|
52
63
|
}
|
|
53
64
|
samples[s] = annoForOneS;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { genesetEnrichmentPayload } from "#types/checkers";
|
|
2
|
+
import crypto from "crypto";
|
|
2
3
|
import fs from "fs";
|
|
3
4
|
import path from "path";
|
|
4
5
|
import serverconfig from "#src/serverconfig.js";
|
|
@@ -6,6 +7,7 @@ import { run_python } from "@sjcrh/proteinpaint-python";
|
|
|
6
7
|
import { run_rust } from "@sjcrh/proteinpaint-rust";
|
|
7
8
|
import { mayLog } from "#src/helpers.ts";
|
|
8
9
|
import { formatElapsedTime } from "#shared";
|
|
10
|
+
import { readCacheFileOrRecompute, stableStringify } from "#src/diffAnalysis.ts";
|
|
9
11
|
const api = {
|
|
10
12
|
endpoint: "genesetEnrichment",
|
|
11
13
|
methods: {
|
|
@@ -19,18 +21,17 @@ const api = {
|
|
|
19
21
|
}
|
|
20
22
|
}
|
|
21
23
|
};
|
|
22
|
-
const cachedir_gsea = path.join(serverconfig.cachedir, "gsea");
|
|
23
24
|
function init({ genomes }) {
|
|
24
25
|
return async (req, res) => {
|
|
25
26
|
try {
|
|
26
27
|
const q = req.query;
|
|
27
28
|
const results = await run_genesetEnrichment_analysis(q, genomes);
|
|
28
29
|
if (!q.geneset_name) {
|
|
29
|
-
if (typeof results != "object") throw "gsea result is not object";
|
|
30
|
+
if (typeof results != "object") throw new Error("gsea result is not object");
|
|
30
31
|
res.send(results);
|
|
31
32
|
return;
|
|
32
33
|
}
|
|
33
|
-
if (typeof results != "string") throw "gsea result is not string";
|
|
34
|
+
if (typeof results != "string") throw new Error("gsea result is not string");
|
|
34
35
|
res.sendFile(results, (err) => {
|
|
35
36
|
fs.unlink(results, () => {
|
|
36
37
|
});
|
|
@@ -45,51 +46,109 @@ function init({ genomes }) {
|
|
|
45
46
|
};
|
|
46
47
|
}
|
|
47
48
|
async function run_genesetEnrichment_analysis(q, genomes) {
|
|
48
|
-
if (!genomes[q.genome].termdbs) throw "termdb database is not available for " + q.genome;
|
|
49
|
+
if (!genomes[q.genome].termdbs) throw new Error("termdb database is not available for " + q.genome);
|
|
50
|
+
if (q.fetchDE) {
|
|
51
|
+
const { genes, fold_change } = await resolveGseaGenesAndFoldChange({ q, genomes });
|
|
52
|
+
return { data: { genes, fold_change } };
|
|
53
|
+
}
|
|
54
|
+
if (q.method == "blitzgsea") {
|
|
55
|
+
return await computeGSEA({ q, genomes });
|
|
56
|
+
}
|
|
57
|
+
if (q.method == "cerno") {
|
|
58
|
+
const { genes, fold_change } = await resolveGseaGenesAndFoldChange({ q, genomes });
|
|
59
|
+
const genesetenrichment_input = {
|
|
60
|
+
genes,
|
|
61
|
+
fold_change,
|
|
62
|
+
db: genomes[q.genome].termdbs.msigdb.cohort.db.connection.name,
|
|
63
|
+
geneset_group: q.geneSetGroup,
|
|
64
|
+
genedb: path.join(serverconfig.tpmasterdir, genomes[q.genome].genedb.dbfile),
|
|
65
|
+
filter_non_coding_genes: q.filter_non_coding_genes
|
|
66
|
+
};
|
|
67
|
+
const time1 = (/* @__PURE__ */ new Date()).valueOf();
|
|
68
|
+
const gsea_output = JSON.parse(await run_rust("cerno", JSON.stringify(genesetenrichment_input)));
|
|
69
|
+
mayLog("Time taken to run CERNO:", formatElapsedTime(Date.now() - time1));
|
|
70
|
+
return gsea_output;
|
|
71
|
+
}
|
|
72
|
+
throw new Error("Unknown method:" + q.method);
|
|
73
|
+
}
|
|
74
|
+
async function resolveGseaGenesAndFoldChange({
|
|
75
|
+
q,
|
|
76
|
+
genomes
|
|
77
|
+
}) {
|
|
78
|
+
if (q.cacheId) {
|
|
79
|
+
if (!q.daRequest) throw new Error("daCacheMissing");
|
|
80
|
+
const result = await readCacheFileOrRecompute({ daRequest: q.daRequest, genomes });
|
|
81
|
+
if (result.cacheId !== q.cacheId) throw new Error("cacheId does not match daRequest");
|
|
82
|
+
return {
|
|
83
|
+
genes: result.geneData.map((g) => g.gene_name),
|
|
84
|
+
fold_change: result.geneData.map((g) => g.fold_change)
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
if (!q.genes || !q.fold_change) throw new Error("requires genes and fold_change when cacheId is absent");
|
|
88
|
+
return { genes: q.genes, fold_change: q.fold_change };
|
|
89
|
+
}
|
|
90
|
+
const pendingTracker_gsea = /* @__PURE__ */ new Map();
|
|
91
|
+
const pendingTracker_img = /* @__PURE__ */ new Map();
|
|
92
|
+
async function computeGSEA({
|
|
93
|
+
q,
|
|
94
|
+
genomes
|
|
95
|
+
}) {
|
|
96
|
+
const { genes, fold_change } = await resolveGseaGenesAndFoldChange({ q, genomes });
|
|
97
|
+
const gseaComputeArg = {
|
|
98
|
+
genes,
|
|
99
|
+
fold_change,
|
|
100
|
+
geneSetGroup: q.geneSetGroup,
|
|
101
|
+
num_permutations: q.num_permutations,
|
|
102
|
+
filter_non_coding_genes: q.filter_non_coding_genes
|
|
103
|
+
};
|
|
104
|
+
const key = stableStringify(gseaComputeArg);
|
|
105
|
+
const pickle_file = crypto.createHash("sha256").update(key).digest("hex").slice(0, 32) + ".pkl";
|
|
106
|
+
const tracker = q.geneset_name ? pendingTracker_img : pendingTracker_gsea;
|
|
107
|
+
const inFlight = tracker.get(pickle_file);
|
|
108
|
+
if (inFlight) return inFlight;
|
|
109
|
+
const work = runGseaPython({ q, genomes, gseaComputeArg, pickle_file });
|
|
110
|
+
tracker.set(pickle_file, work);
|
|
111
|
+
return work.finally(() => tracker.delete(pickle_file));
|
|
112
|
+
}
|
|
113
|
+
async function runGseaPython({
|
|
114
|
+
q,
|
|
115
|
+
genomes,
|
|
116
|
+
gseaComputeArg,
|
|
117
|
+
pickle_file
|
|
118
|
+
}) {
|
|
119
|
+
const cachedir_gsea = path.join(serverconfig.cachedir, "gsea");
|
|
49
120
|
const genesetenrichment_input = {
|
|
50
|
-
genes:
|
|
51
|
-
fold_change:
|
|
121
|
+
genes: gseaComputeArg.genes,
|
|
122
|
+
fold_change: gseaComputeArg.fold_change,
|
|
52
123
|
db: genomes[q.genome].termdbs.msigdb.cohort.db.connection.name,
|
|
53
|
-
|
|
54
|
-
geneset_group: q.geneSetGroup,
|
|
124
|
+
geneset_group: gseaComputeArg.geneSetGroup,
|
|
55
125
|
genedb: path.join(serverconfig.tpmasterdir, genomes[q.genome].genedb.dbfile),
|
|
56
|
-
filter_non_coding_genes:
|
|
126
|
+
filter_non_coding_genes: gseaComputeArg.filter_non_coding_genes,
|
|
127
|
+
cachedir: cachedir_gsea,
|
|
128
|
+
pickle_file,
|
|
129
|
+
geneset_name: q.geneset_name,
|
|
130
|
+
num_permutations: gseaComputeArg.num_permutations
|
|
57
131
|
};
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
data_found = true;
|
|
74
|
-
} else if (line.startsWith("image: ")) {
|
|
75
|
-
result = JSON.parse(line.replace("image: ", ""));
|
|
76
|
-
image_found = true;
|
|
77
|
-
} else {
|
|
78
|
-
mayLog(line);
|
|
79
|
-
}
|
|
132
|
+
const time1 = (/* @__PURE__ */ new Date()).valueOf();
|
|
133
|
+
const gsea_output = await run_python("gsea.py", "/" + JSON.stringify(genesetenrichment_input));
|
|
134
|
+
mayLog("Time taken to run blitzgsea:", formatElapsedTime(Date.now() - time1));
|
|
135
|
+
let result;
|
|
136
|
+
let data_found = false;
|
|
137
|
+
let image_found = false;
|
|
138
|
+
for (const line of gsea_output.split("\n")) {
|
|
139
|
+
if (line.startsWith("result: ")) {
|
|
140
|
+
result = JSON.parse(line.replace("result: ", ""));
|
|
141
|
+
data_found = true;
|
|
142
|
+
} else if (line.startsWith("image: ")) {
|
|
143
|
+
result = JSON.parse(line.replace("image: ", ""));
|
|
144
|
+
image_found = true;
|
|
145
|
+
} else {
|
|
146
|
+
mayLog(line);
|
|
80
147
|
}
|
|
81
|
-
if (data_found) return result;
|
|
82
|
-
const image_file_name = path.join(cachedir_gsea, result.image_file);
|
|
83
|
-
if (image_found) return image_file_name;
|
|
84
|
-
throw "data or image not found in gsea output; this should not happen";
|
|
85
|
-
} else if (q.method == "cerno") {
|
|
86
|
-
const time1 = (/* @__PURE__ */ new Date()).valueOf();
|
|
87
|
-
gsea_output = JSON.parse(await run_rust("cerno", JSON.stringify(genesetenrichment_input)));
|
|
88
|
-
mayLog("Time taken to run CERNO:", formatElapsedTime(Date.now() - time1));
|
|
89
|
-
return gsea_output;
|
|
90
|
-
} else {
|
|
91
|
-
throw "Unknown method:" + q.method;
|
|
92
148
|
}
|
|
149
|
+
if (data_found) return result;
|
|
150
|
+
if (image_found) return path.join(cachedir_gsea, result.image_file);
|
|
151
|
+
throw new Error("data or image not found in gsea output; this should not happen");
|
|
93
152
|
}
|
|
94
153
|
export {
|
|
95
154
|
api
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { ProfileScoresPayload } from "#types/checkers";
|
|
2
|
+
import { getData } from "../src/termdb.matrix.js";
|
|
3
|
+
const api = {
|
|
4
|
+
endpoint: "termdb/profileRadar2Scores",
|
|
5
|
+
methods: {
|
|
6
|
+
get: {
|
|
7
|
+
...ProfileScoresPayload,
|
|
8
|
+
init
|
|
9
|
+
},
|
|
10
|
+
post: {
|
|
11
|
+
...ProfileScoresPayload,
|
|
12
|
+
init
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
function init({ genomes }) {
|
|
17
|
+
return async (req, res) => {
|
|
18
|
+
try {
|
|
19
|
+
const g = genomes[req.query.genome];
|
|
20
|
+
if (!g) throw "invalid genome name";
|
|
21
|
+
const ds = g.datasets?.[req.query.dslabel];
|
|
22
|
+
const result = await getScores(req.query, ds);
|
|
23
|
+
res.send(result);
|
|
24
|
+
} catch (e) {
|
|
25
|
+
console.log(e);
|
|
26
|
+
res.send({ status: "error", error: e.message || e });
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function derivePrefix(query) {
|
|
31
|
+
const firstScoreId = query.scoreTerms?.[0]?.score?.term?.id;
|
|
32
|
+
if (firstScoreId?.startsWith("F")) return "F";
|
|
33
|
+
if (firstScoreId?.startsWith("A")) return "A";
|
|
34
|
+
for (const entry of query.filter?.lst || []) {
|
|
35
|
+
const id = entry.tvs?.term?.id;
|
|
36
|
+
if (id?.startsWith("F")) return "F";
|
|
37
|
+
if (id?.startsWith("A")) return "A";
|
|
38
|
+
}
|
|
39
|
+
throw "cannot determine cohort prefix from scoreTerms or filter term IDs";
|
|
40
|
+
}
|
|
41
|
+
async function getScores(query, ds) {
|
|
42
|
+
const { activeCohort, clientAuthResult } = query.__protected__;
|
|
43
|
+
const prefix = derivePrefix(query);
|
|
44
|
+
const facilityTermId = `${prefix}UNIT`;
|
|
45
|
+
const facilityTW = { term: { id: facilityTermId }, q: {} };
|
|
46
|
+
const terms = [facilityTW];
|
|
47
|
+
for (const t of query.scoreTerms) {
|
|
48
|
+
terms.push(t.score);
|
|
49
|
+
if (t.maxScore?.term) terms.push(t.maxScore);
|
|
50
|
+
}
|
|
51
|
+
if (!query.filterByUserSites) {
|
|
52
|
+
query.__protected__.ignoredTermIds.push(facilityTermId);
|
|
53
|
+
}
|
|
54
|
+
const cohortAuth = clientAuthResult[activeCohort];
|
|
55
|
+
const isPublic = !cohortAuth?.role || cohortAuth.role === "public";
|
|
56
|
+
const userSites = cohortAuth?.sites;
|
|
57
|
+
const raw = await getData(
|
|
58
|
+
{
|
|
59
|
+
terms,
|
|
60
|
+
filter: query.filter,
|
|
61
|
+
__protected__: query.__protected__
|
|
62
|
+
},
|
|
63
|
+
ds
|
|
64
|
+
);
|
|
65
|
+
if (raw.error) throw raw.error;
|
|
66
|
+
const sampleList = Object.values(raw.samples);
|
|
67
|
+
let sites = sampleList.map((s) => {
|
|
68
|
+
const val = s[facilityTW.$id].value;
|
|
69
|
+
let label = facilityTW.term.values?.[val]?.label || val;
|
|
70
|
+
if (label.length > 50) label = label.slice(0, 47) + "...";
|
|
71
|
+
return { value: val, label };
|
|
72
|
+
});
|
|
73
|
+
if (userSites && query.filterByUserSites) {
|
|
74
|
+
sites = sites.filter((s) => userSites.includes(s.value));
|
|
75
|
+
}
|
|
76
|
+
sites.sort((a, b) => a.label.localeCompare(b.label));
|
|
77
|
+
const samples = Object.values(raw.samples);
|
|
78
|
+
const eligibleSamples = userSites && query.filterByUserSites ? samples.filter((s) => userSites.includes(s[facilityTW.$id].value)) : samples;
|
|
79
|
+
const term2Score = {};
|
|
80
|
+
for (const d of query.scoreTerms) {
|
|
81
|
+
const score = computeMedianPercentage(d, eligibleSamples);
|
|
82
|
+
if (score !== null) term2Score[d.score.term.id] = score;
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
term2Score,
|
|
86
|
+
sites: isPublic ? [] : sites,
|
|
87
|
+
n: eligibleSamples.length
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function computeMedianPercentage(d, samples) {
|
|
91
|
+
const percentages = [];
|
|
92
|
+
for (const s of samples) {
|
|
93
|
+
const scoreValue = s[d.score.$id]?.value;
|
|
94
|
+
if (scoreValue == null) continue;
|
|
95
|
+
let maxScoreValue = null;
|
|
96
|
+
if (typeof d.maxScore === "number") {
|
|
97
|
+
maxScoreValue = d.maxScore;
|
|
98
|
+
} else {
|
|
99
|
+
maxScoreValue = s[d.maxScore.$id]?.value;
|
|
100
|
+
}
|
|
101
|
+
if (maxScoreValue == null || maxScoreValue === 0) continue;
|
|
102
|
+
percentages.push(scoreValue / maxScoreValue * 100);
|
|
103
|
+
}
|
|
104
|
+
if (percentages.length === 0) return null;
|
|
105
|
+
percentages.sort((a, b) => a - b);
|
|
106
|
+
const mid = Math.floor(percentages.length / 2);
|
|
107
|
+
const median = percentages.length % 2 !== 0 ? percentages[mid] : (percentages[mid - 1] + percentages[mid]) / 2;
|
|
108
|
+
return Math.round(median);
|
|
109
|
+
}
|
|
110
|
+
export {
|
|
111
|
+
api
|
|
112
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { ProfileScoresPayload } from "#types/checkers";
|
|
2
|
+
import { getData } from "../src/termdb.matrix.js";
|
|
3
|
+
const api = {
|
|
4
|
+
endpoint: "termdb/profileRadarFacility2Scores",
|
|
5
|
+
methods: {
|
|
6
|
+
get: {
|
|
7
|
+
...ProfileScoresPayload,
|
|
8
|
+
init
|
|
9
|
+
},
|
|
10
|
+
post: {
|
|
11
|
+
...ProfileScoresPayload,
|
|
12
|
+
init
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
function init({ genomes }) {
|
|
17
|
+
return async (req, res) => {
|
|
18
|
+
try {
|
|
19
|
+
const g = genomes[req.query.genome];
|
|
20
|
+
if (!g) throw "invalid genome name";
|
|
21
|
+
const ds = g.datasets?.[req.query.dslabel];
|
|
22
|
+
const result = await getScores(req.query, ds);
|
|
23
|
+
res.send(result);
|
|
24
|
+
} catch (e) {
|
|
25
|
+
console.log(e);
|
|
26
|
+
res.send({ status: "error", error: e.message || e });
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function derivePrefix(query) {
|
|
31
|
+
const firstScoreId = query.scoreTerms?.[0]?.score?.term?.id;
|
|
32
|
+
if (firstScoreId?.startsWith("F")) return "F";
|
|
33
|
+
if (firstScoreId?.startsWith("A")) return "A";
|
|
34
|
+
for (const entry of query.filter?.lst || []) {
|
|
35
|
+
const id = entry.tvs?.term?.id;
|
|
36
|
+
if (id?.startsWith("F")) return "F";
|
|
37
|
+
if (id?.startsWith("A")) return "A";
|
|
38
|
+
}
|
|
39
|
+
throw "cannot determine cohort prefix from scoreTerms or filter term IDs";
|
|
40
|
+
}
|
|
41
|
+
async function getScores(query, ds) {
|
|
42
|
+
const { activeCohort, clientAuthResult } = query.__protected__;
|
|
43
|
+
const prefix = derivePrefix(query);
|
|
44
|
+
const facilityTermId = `${prefix}UNIT`;
|
|
45
|
+
const facilityTW = { term: { id: facilityTermId }, q: {} };
|
|
46
|
+
const terms = [facilityTW];
|
|
47
|
+
for (const t of query.scoreTerms) {
|
|
48
|
+
terms.push(t.score);
|
|
49
|
+
if (t.maxScore?.term) terms.push(t.maxScore);
|
|
50
|
+
}
|
|
51
|
+
if (!query.filterByUserSites) {
|
|
52
|
+
query.__protected__.ignoredTermIds.push(facilityTermId);
|
|
53
|
+
}
|
|
54
|
+
const cohortAuth = clientAuthResult[activeCohort];
|
|
55
|
+
const isPublic = !cohortAuth?.role || cohortAuth.role === "public";
|
|
56
|
+
const isAdmin = cohortAuth?.role === "admin";
|
|
57
|
+
const userSites = cohortAuth?.sites;
|
|
58
|
+
const raw = await getData(
|
|
59
|
+
{
|
|
60
|
+
terms,
|
|
61
|
+
filter: query.filter,
|
|
62
|
+
__protected__: query.__protected__
|
|
63
|
+
},
|
|
64
|
+
ds
|
|
65
|
+
);
|
|
66
|
+
if (raw.error) throw raw.error;
|
|
67
|
+
const samples = Object.values(raw.samples);
|
|
68
|
+
let sites = samples.map((s) => {
|
|
69
|
+
const val = s[facilityTW.$id].value;
|
|
70
|
+
let label = facilityTW.term.values?.[val]?.label || val;
|
|
71
|
+
if (label.length > 50) label = label.slice(0, 47) + "...";
|
|
72
|
+
return { value: val, label };
|
|
73
|
+
});
|
|
74
|
+
if (userSites && !isAdmin) {
|
|
75
|
+
sites = sites.filter((s) => userSites.includes(s.value));
|
|
76
|
+
}
|
|
77
|
+
sites.sort((a, b) => a.label.localeCompare(b.label));
|
|
78
|
+
const eligibleSamples = userSites && query.filterByUserSites ? samples.filter((s) => userSites.includes(s[facilityTW.$id].value)) : samples;
|
|
79
|
+
const aggregateScore = {};
|
|
80
|
+
for (const d of query.scoreTerms) {
|
|
81
|
+
const score = computeMedianPercentage(d, eligibleSamples);
|
|
82
|
+
if (score !== null) aggregateScore[d.score.term.id] = score;
|
|
83
|
+
}
|
|
84
|
+
let targetSiteValue = null;
|
|
85
|
+
if (query.facilitySite) {
|
|
86
|
+
if (isAdmin) targetSiteValue = query.facilitySite;
|
|
87
|
+
else if (userSites?.includes(query.facilitySite)) targetSiteValue = query.facilitySite;
|
|
88
|
+
}
|
|
89
|
+
if (!targetSiteValue && sites.length > 0) targetSiteValue = sites[0].value;
|
|
90
|
+
let sampleData = void 0;
|
|
91
|
+
if (!isPublic && targetSiteValue) {
|
|
92
|
+
const sampleRow = samples.find((s) => s[facilityTW.$id].value == targetSiteValue);
|
|
93
|
+
if (sampleRow) {
|
|
94
|
+
const site = sites.find((s) => s.value == targetSiteValue) || {
|
|
95
|
+
value: targetSiteValue,
|
|
96
|
+
label: facilityTW.term.values?.[targetSiteValue]?.label || targetSiteValue
|
|
97
|
+
};
|
|
98
|
+
const singleSiteScore = {};
|
|
99
|
+
for (const d of query.scoreTerms) {
|
|
100
|
+
const percent = computeSinglePercentage(d, sampleRow);
|
|
101
|
+
if (percent !== null) singleSiteScore[d.score.term.id] = percent;
|
|
102
|
+
}
|
|
103
|
+
sampleData = { term2Score: singleSiteScore, site, sites, n: 1 };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
term2Score: aggregateScore,
|
|
108
|
+
sampleData,
|
|
109
|
+
// Public users: empty (chart should be unreachable anyway via isSupportedChartOverride)
|
|
110
|
+
sites: isPublic ? [] : sites,
|
|
111
|
+
n: eligibleSamples.length
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function computeSinglePercentage(d, sample) {
|
|
115
|
+
const scoreValue = sample[d.score.$id]?.value;
|
|
116
|
+
if (scoreValue == null) return null;
|
|
117
|
+
let maxScoreValue = null;
|
|
118
|
+
if (typeof d.maxScore === "number") {
|
|
119
|
+
maxScoreValue = d.maxScore;
|
|
120
|
+
} else {
|
|
121
|
+
maxScoreValue = sample[d.maxScore.$id]?.value;
|
|
122
|
+
}
|
|
123
|
+
if (maxScoreValue == null || maxScoreValue === 0) return null;
|
|
124
|
+
return Math.round(scoreValue / maxScoreValue * 100);
|
|
125
|
+
}
|
|
126
|
+
function computeMedianPercentage(d, samples) {
|
|
127
|
+
const percentages = [];
|
|
128
|
+
for (const s of samples) {
|
|
129
|
+
const scoreValue = s[d.score.$id]?.value;
|
|
130
|
+
if (scoreValue == null) continue;
|
|
131
|
+
let maxScoreValue = null;
|
|
132
|
+
if (typeof d.maxScore === "number") {
|
|
133
|
+
maxScoreValue = d.maxScore;
|
|
134
|
+
} else {
|
|
135
|
+
maxScoreValue = s[d.maxScore.$id]?.value;
|
|
136
|
+
}
|
|
137
|
+
if (maxScoreValue == null || maxScoreValue === 0) continue;
|
|
138
|
+
percentages.push(scoreValue / maxScoreValue * 100);
|
|
139
|
+
}
|
|
140
|
+
if (percentages.length === 0) return null;
|
|
141
|
+
percentages.sort((a, b) => a - b);
|
|
142
|
+
const mid = Math.floor(percentages.length / 2);
|
|
143
|
+
const median = percentages.length % 2 !== 0 ? percentages[mid] : (percentages[mid - 1] + percentages[mid]) / 2;
|
|
144
|
+
return Math.round(median);
|
|
145
|
+
}
|
|
146
|
+
export {
|
|
147
|
+
api
|
|
148
|
+
};
|
|
@@ -75,6 +75,27 @@ function validateQuery(ds, connection) {
|
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
77
|
const imageId = imageRow.id;
|
|
78
|
+
const duplicateCheckSql = `
|
|
79
|
+
SELECT id
|
|
80
|
+
FROM project_annotations
|
|
81
|
+
WHERE project_id = ?
|
|
82
|
+
AND image_id = ?
|
|
83
|
+
AND coordinates = ?
|
|
84
|
+
LIMIT 1
|
|
85
|
+
`;
|
|
86
|
+
const duplicateCheckStmt = connection.prepare(duplicateCheckSql);
|
|
87
|
+
const duplicateRow = duplicateCheckStmt.get(projectId, imageId, coords);
|
|
88
|
+
if (duplicateRow) {
|
|
89
|
+
const deleteDuplicateSql = `
|
|
90
|
+
DELETE FROM project_annotations
|
|
91
|
+
WHERE id = ?
|
|
92
|
+
`;
|
|
93
|
+
const deleteDuplicateStmt = connection.prepare(deleteDuplicateSql);
|
|
94
|
+
deleteDuplicateStmt.run(duplicateRow.id);
|
|
95
|
+
console.log(
|
|
96
|
+
`Deleted duplicate annotation with id=${duplicateRow.id} for project_id=${projectId}, image_id=${imageId}.`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
78
99
|
const insertSql = `
|
|
79
100
|
INSERT INTO project_annotations (
|
|
80
101
|
project_id, user_id, coordinates, timestamp, status, class_id, image_id
|