@sjcrh/proteinpaint-server 2.187.0 → 2.188.1

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.
@@ -244,6 +244,8 @@ function termdb_test_default() {
244
244
  plots: [
245
245
  {
246
246
  name: "TermdbTest TSNE",
247
+ description: "Transcriptome t-SNE plot based on termdb data which is a 2D or 3D visualization of gene expression data where each point represents a single cell (or sample) and the distance between points reflects how similar their gene expression profiles are.",
248
+ descriptionShort: "transcriptome t-SNE plot",
247
249
  dimension: 2,
248
250
  file: "files/hg38/TermdbTest/tsne.txt",
249
251
  colorTW: { id: "diaggrp" },
@@ -400,9 +402,7 @@ function termdb_test_default() {
400
402
  unit: "M-value"
401
403
  }
402
404
  },
403
- topVariablyExpressedGenes: {
404
- src: "native"
405
- },
405
+ topVariablyExpressedGenes: {},
406
406
  rnaseqGeneCount: {
407
407
  storage_type: "HDF5",
408
408
  file: "files/hg38/TermdbTest/rnaseq/TermdbTest.geneCounts.new.h5"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjcrh/proteinpaint-server",
3
- "version": "2.187.0",
3
+ "version": "2.188.1",
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.187.0",
66
- "@sjcrh/proteinpaint-r": "2.181.0",
67
- "@sjcrh/proteinpaint-rust": "2.186.0",
68
- "@sjcrh/proteinpaint-shared": "2.187.0",
69
- "@sjcrh/proteinpaint-types": "2.187.0",
65
+ "@sjcrh/proteinpaint-python": "2.188.0",
66
+ "@sjcrh/proteinpaint-r": "2.188.0",
67
+ "@sjcrh/proteinpaint-rust": "2.188.0",
68
+ "@sjcrh/proteinpaint-shared": "2.188.1",
69
+ "@sjcrh/proteinpaint-types": "2.188.1",
70
70
  "@types/express": "^5.0.0",
71
71
  "@types/express-session": "^1.18.1",
72
72
  "better-sqlite3": "^12.4.1",
@@ -146,7 +146,9 @@ function editProject(connection, project) {
146
146
  function deleteProject(connection, projectId) {
147
147
  if (!projectId) throw new Error("Invalid project ID [aiProjectAdmin route deleteProject()]");
148
148
  const stmts = [
149
+ { sql: "DELETE FROM project_flagged_annotations WHERE project_id = ?", params: [[projectId]] },
149
150
  { sql: "DELETE FROM project_annotations WHERE project_id = ?", params: [[projectId]] },
151
+ { sql: "DELETE FROM project_flagged_predictions WHERE project_id = ?", params: [[projectId]] },
150
152
  { sql: "DELETE FROM project_classes WHERE project_id = ?", params: [[projectId]] },
151
153
  { sql: "DELETE FROM project_images WHERE project_id = ?", params: [[projectId]] },
152
154
  { sql: "DELETE FROM project_users WHERE project_id = ?", params: [[projectId]] },
@@ -1,4 +1,5 @@
1
1
  import { aiProjectSelectedWSImagesResponsePayload } from "#types/checkers";
2
+ import { createSelectionID, SelectionPrefixes, FlagStatus } from "#shared";
2
3
  import { getDbConnection } from "#src/aiHistoDBConnection.ts";
3
4
  const api = {
4
5
  endpoint: "aiProjectSelectedWSImages",
@@ -54,16 +55,23 @@ function init({ genomes }) {
54
55
  return `${zc[0]},${zc[1]}`;
55
56
  }).filter(Boolean)
56
57
  );
57
- let flaggedPredictions = /* @__PURE__ */ new Set();
58
+ let flaggedPredictions = /* @__PURE__ */ new Map();
58
59
  if (typeof ds.queries.WSImages.getFlaggedPredictions === "function") {
59
- flaggedPredictions = await ds.queries.WSImages.getFlaggedPredictions(projectId);
60
+ flaggedPredictions = await ds.queries.WSImages.getFlaggedPredictions(projectId, wsimageFilename);
60
61
  }
61
62
  wsimage.predictions = (predictions || []).map((p) => {
62
63
  const label = classMap.get(p.class) ?? p.class;
63
- return { ...p, class: label };
64
+ const flagged_counterpart = flaggedPredictions.get(JSON.stringify(p.zoomCoordinates));
65
+ return {
66
+ ...p,
67
+ class: label,
68
+ timestamp: flagged_counterpart?.timestamp ?? (/* @__PURE__ */ new Date()).toISOString(),
69
+ flag: flagged_counterpart?.flag ?? FlagStatus.Normal,
70
+ id: createSelectionID(SelectionPrefixes.Prediction, p.zoomCoordinates)
71
+ };
64
72
  }).filter((p) => {
65
73
  const key = `${p.zoomCoordinates[0]},${p.zoomCoordinates[1]}`;
66
- return !annotationKeys.has(key) && !flaggedPredictions.has(key);
74
+ return !annotationKeys.has(key) && p.flag !== FlagStatus.Deleted;
67
75
  });
68
76
  }
69
77
  wsimages.push(wsimage);
@@ -91,23 +99,43 @@ async function validate_query_getWSIClassesQuery(ds) {
91
99
  function validateWSIAnnotationsQuery(ds, connection) {
92
100
  if (!ds.queries?.WSImages?.db) return;
93
101
  const GET_ANNOTATIONS_SQL = `
94
- SELECT
95
- pa.id,
96
- pa.project_id,
97
- pa.user_id,
98
- pa.coordinates,
99
- pa.timestamp,
100
- pa.status,
101
- pc.label AS label
102
- FROM project_annotations pa
103
- INNER JOIN project_images pi
104
- ON pi.id = pa.image_id
105
- LEFT JOIN project_classes pc
106
- ON pc.id = pa.class_id
107
- WHERE pa.project_id = ?
108
- AND pi.image_path = ?
109
- AND pa.status = 1
110
- ORDER BY pa.timestamp DESC, pa.id DESC
102
+ SELECT *
103
+ FROM (
104
+ SELECT
105
+ pa.id,
106
+ pa.project_id,
107
+ pa.user_id,
108
+ pa.coordinates,
109
+ pa.timestamp,
110
+ pa.flagged,
111
+ pc.label AS label
112
+ FROM project_flagged_annotations pa
113
+ INNER JOIN project_images pi
114
+ ON pi.id = pa.image_id
115
+ LEFT JOIN project_classes pc
116
+ ON pc.id = pa.class_id
117
+ WHERE pa.project_id = ?
118
+ AND pi.image_path = ?
119
+
120
+ UNION ALL
121
+
122
+ SELECT
123
+ pa.id,
124
+ pa.project_id,
125
+ pa.user_id,
126
+ pa.coordinates,
127
+ pa.timestamp,
128
+ ${FlagStatus.Normal} AS flagged,
129
+ pc.label AS label
130
+ FROM project_annotations pa
131
+ INNER JOIN project_images pi
132
+ ON pi.id = pa.image_id
133
+ LEFT JOIN project_classes pc
134
+ ON pc.id = pa.class_id
135
+ WHERE pa.project_id = ?
136
+ AND pi.image_path = ?
137
+ )
138
+ ORDER BY timestamp DESC, id DESC
111
139
  `;
112
140
  const GET_PROJECT_IMAGES_SQL = `
113
141
  SELECT DISTINCT pi.image_path AS image_path
@@ -116,16 +144,22 @@ function validateWSIAnnotationsQuery(ds, connection) {
116
144
  ORDER BY pi.id
117
145
  `;
118
146
  const GET_FLAGGED_PREDICTIONS_SQL = `
119
- SELECT coordinates
120
- FROM project_flagged_predictions
121
- WHERE project_id = ?
147
+ SELECT
148
+ pa.coordinates,
149
+ pa.flag_type,
150
+ pa.timestamp
151
+ FROM project_flagged_predictions pa
152
+ INNER JOIN project_images pi
153
+ ON pi.id = pa.image_id
154
+ WHERE pa.project_id = ?
155
+ AND pa.image_id = ?
122
156
  `;
123
157
  if (!ds.queries) ds.queries = {};
124
158
  if (!ds.queries.WSImages) ds.queries.WSImages = {};
125
159
  ds.queries.WSImages.getWSIAnnotations = async (projectId, filename) => {
126
160
  try {
127
161
  const stmt = connection.prepare(GET_ANNOTATIONS_SQL);
128
- const rows = stmt.all(projectId, filename);
162
+ const rows = stmt.all(projectId, filename, projectId, filename);
129
163
  return rows.map((r) => {
130
164
  let coords = [NaN, NaN];
131
165
  try {
@@ -141,7 +175,9 @@ function validateWSIAnnotationsQuery(ds, connection) {
141
175
  zoomCoordinates: coords,
142
176
  class: r.label ?? "",
143
177
  status: r.status,
144
- timestamp: r.timestamp
178
+ flag: r.flagged === void 0 ? FlagStatus.Normal : r.flagged,
179
+ timestamp: r.timestamp,
180
+ id: createSelectionID(SelectionPrefixes.Annotation, coords)
145
181
  };
146
182
  });
147
183
  } catch (error) {
@@ -159,28 +195,32 @@ function validateWSIAnnotationsQuery(ds, connection) {
159
195
  return [];
160
196
  }
161
197
  };
162
- ds.queries.WSImages.getFlaggedPredictions = async (projectId) => {
198
+ ds.queries.WSImages.getFlaggedPredictions = async (projectId, filename) => {
199
+ const predictionMap = /* @__PURE__ */ new Map();
200
+ const getImageIdSql = `
201
+ SELECT id
202
+ FROM project_images
203
+ WHERE project_id = ?
204
+ AND image_path = ?
205
+ LIMIT 1
206
+ `;
207
+ const getImageStmt = connection.prepare(getImageIdSql);
208
+ const imageRow = getImageStmt.get(projectId, filename);
209
+ if (!imageRow?.id) {
210
+ return predictionMap;
211
+ }
212
+ const imageId = Math.floor(imageRow.id);
163
213
  try {
164
214
  const stmt = connection.prepare(GET_FLAGGED_PREDICTIONS_SQL);
165
- const rows = stmt.all(projectId) || [];
166
- const keys = /* @__PURE__ */ new Set();
167
- for (const r of rows) {
168
- try {
169
- const parsed = typeof r.coordinates === "string" ? JSON.parse(r.coordinates) : r.coordinates;
170
- if (Array.isArray(parsed) && parsed.length >= 2) {
171
- const x = Number(parsed[0]);
172
- const y = Number(parsed[1]);
173
- if (!Number.isNaN(x) && !Number.isNaN(y)) {
174
- keys.add(`${x},${y}`);
175
- }
176
- }
177
- } catch {
178
- }
215
+ const rows = stmt.all(projectId, imageId) || [];
216
+ for (const p of rows) {
217
+ if (p.coordinates && typeof p.coordinates === "string" && typeof p.flag_type === "number")
218
+ predictionMap.set(p.coordinates, { flag: p.flag_type, timestamp: p.timestamp });
179
219
  }
180
- return keys;
220
+ return predictionMap;
181
221
  } catch (error) {
182
222
  console.error("Error loading flagged predictions:", error);
183
- return /* @__PURE__ */ new Set();
223
+ return predictionMap;
184
224
  }
185
225
  };
186
226
  }
@@ -3,7 +3,7 @@ import serverconfig from "#src/serverconfig.js";
3
3
  import { brainImagingPayload } from "#types/checkers";
4
4
  import { getData } from "../src/termdb.matrix.js";
5
5
  import { isNumericTerm } from "#shared";
6
- import { getColors } from "#shared/common.js";
6
+ import { getColors } from "#shared";
7
7
  import { run_python } from "@sjcrh/proteinpaint-python";
8
8
  const api = {
9
9
  endpoint: "brainImaging",
@@ -1,4 +1,5 @@
1
1
  import { deleteWSITileSelectionPayload } from "#types/checkers";
2
+ import { checkSelectionType, SelectionPrefixes, FlagStatus } from "#shared";
2
3
  import { getDbConnection } from "#src/aiHistoDBConnection.ts";
3
4
  const api = {
4
5
  endpoint: `deleteWSITileSelection`,
@@ -48,7 +49,8 @@ async function validate_query_deleteWSIAnnotation(ds) {
48
49
  }
49
50
  function validateQuery(ds, connection) {
50
51
  ds.queries.WSImages.deleteAnnotation = async (query) => {
51
- if (query.tileSelectionType === 0) {
52
+ const zoomCoordinates = JSON.stringify(query.tileSelection.zoomCoordinates);
53
+ if (checkSelectionType(query.tileSelection, SelectionPrefixes.Prediction) && query.tileSelection.flag !== FlagStatus.Normal) {
52
54
  try {
53
55
  const projectId = query.projectId;
54
56
  if (projectId == null) {
@@ -57,21 +59,41 @@ function validateQuery(ds, connection) {
57
59
  error: "Missing required field: projectId"
58
60
  };
59
61
  }
60
- const predictionId = query.predictionClassId;
61
- const zoomCoordinates = JSON.stringify(query.tileSelection.zoomCoordinates);
62
- const flagType = 0;
62
+ const predictionId = query.classID;
63
+ const flagType = query.tileSelection.flag;
63
64
  if (predictionId == null) {
64
65
  return {
65
66
  status: "error",
66
67
  error: "Missing prediction id in tileSelection (expected predictionId, prediction.id, or id)."
67
68
  };
68
69
  }
69
- const insertSql = `
70
- INSERT INTO project_flagged_predictions (project_id, prediction_class_id, coordinates, flag_type)
71
- VALUES (?, ?, ?, ?)
72
- `;
73
- const insertStmt = connection.prepare(insertSql);
74
- insertStmt.run(projectId, predictionId, zoomCoordinates, flagType);
70
+ const getImageIdSql = `
71
+ SELECT id FROM project_images
72
+ WHERE project_id = ? AND image_path = ? LIMIT 1
73
+ `;
74
+ const imageRow = connection.prepare(getImageIdSql).get(projectId, query.wsimage);
75
+ const imageId = imageRow?.id;
76
+ if (!imageId) {
77
+ return { status: "error", error: "Image not found" };
78
+ }
79
+ connection.prepare(
80
+ `
81
+ DELETE FROM project_flagged_predictions
82
+ WHERE project_id = ? AND coordinates = ?
83
+ AND image_id = (
84
+ SELECT id FROM project_images
85
+ WHERE project_id = ?
86
+ AND image_path = ?
87
+ )
88
+ `
89
+ ).run(projectId, zoomCoordinates, projectId, query.wsimage);
90
+ connection.prepare(
91
+ `
92
+ INSERT INTO project_flagged_predictions
93
+ (project_id, prediction_class_id, coordinates, flag_type, timestamp, image_id)
94
+ VALUES (?, ?, ?, ?, ?, ?)
95
+ `
96
+ ).run(projectId, predictionId, zoomCoordinates, flagType, (/* @__PURE__ */ new Date()).toISOString(), imageId);
75
97
  return { status: "ok" };
76
98
  } catch (error) {
77
99
  console.error("Error inserting flagged prediction:", error);
@@ -82,7 +104,20 @@ function validateQuery(ds, connection) {
82
104
  }
83
105
  } else
84
106
  try {
85
- const deleteSql = `
107
+ connection.prepare(
108
+ `
109
+ DELETE FROM project_flagged_annotations
110
+ WHERE project_id = ?
111
+ AND coordinates = ?
112
+ AND image_id = (
113
+ SELECT id FROM project_images
114
+ WHERE project_id = ?
115
+ AND image_path = ?
116
+ )
117
+ `
118
+ ).run(query.projectId, zoomCoordinates, query.projectId, query.wsimage);
119
+ connection.prepare(
120
+ `
86
121
  DELETE FROM project_annotations
87
122
  WHERE project_id = ?
88
123
  AND coordinates = ?
@@ -91,10 +126,8 @@ function validateQuery(ds, connection) {
91
126
  WHERE project_id = ?
92
127
  AND image_path = ?
93
128
  )
94
- `;
95
- const coords = JSON.stringify(query.tileSelection?.zoomCoordinates);
96
- const stmt = connection.prepare(deleteSql);
97
- stmt.run(query.projectId, coords, query.projectId, query.wsimage);
129
+ `
130
+ ).run(query.projectId, zoomCoordinates, query.projectId, query.wsimage);
98
131
  return { status: "ok" };
99
132
  } catch (error) {
100
133
  console.error("Error deleting annotation:", error);
@@ -8,6 +8,7 @@ import { run_rust } from "@sjcrh/proteinpaint-rust";
8
8
  import { mayLog } from "#src/helpers.ts";
9
9
  import { formatElapsedTime } from "#shared";
10
10
  import { readCacheFileOrRecompute, stableStringify } from "#src/diffAnalysis.ts";
11
+ import { get_ds_tdb } from "#src/termdb.js";
11
12
  const api = {
12
13
  endpoint: "genesetEnrichment",
13
14
  methods: {
@@ -100,6 +101,28 @@ async function resolveGseaGenesAndFoldChange({
100
101
  }
101
102
  throw new Error(`unexpected result kind: ${result.kind}`);
102
103
  }
104
+ if (q.dapParams) {
105
+ const genome = genomes[q.genome];
106
+ if (!genome) throw new Error("invalid genome");
107
+ const [ds] = get_ds_tdb(genome, q);
108
+ const { organism, assay, cohort } = q.dapParams;
109
+ const cohortConfig = ds.queries?.proteome?.organisms?.[organism]?.assays?.[assay]?.cohorts?.[cohort];
110
+ if (!cohortConfig?.DAPfile) throw new Error("DAP file not configured for this cohort");
111
+ const filePath = path.join(serverconfig.tpmasterdir, cohortConfig.DAPfile);
112
+ const content = await fs.promises.readFile(filePath, "utf8");
113
+ const lines = content.trim().split("\n");
114
+ const genes = [];
115
+ const fold_change = [];
116
+ for (let i = 1; i < lines.length; i++) {
117
+ const parts = lines[i].split(" ");
118
+ if (parts.length < 4) continue;
119
+ const fc = Number(parts[2]);
120
+ if (!Number.isFinite(fc)) continue;
121
+ genes.push(parts[1]);
122
+ fold_change.push(fc);
123
+ }
124
+ return { genes, fold_change };
125
+ }
103
126
  if (!q.genes || !q.fold_change) throw new Error("requires genes and fold_change when cacheId is absent");
104
127
  return { genes: q.genes, fold_change: q.fold_change };
105
128
  }
@@ -1,4 +1,5 @@
1
1
  import { saveWSIAnnotationPayload } from "#types/checkers";
2
+ import { FlagStatus, SelectionPrefixes, checkSelectionType } from "#shared";
2
3
  import { getDbConnection } from "#src/aiHistoDBConnection.ts";
3
4
  const routePath = "saveWSIAnnotation";
4
5
  const api = {
@@ -47,12 +48,21 @@ async function validate_query_saveWSIAnnotation(ds) {
47
48
  function validateQuery(ds, connection) {
48
49
  ds.queries.WSImages.saveWSIAnnotation = async (annotation) => {
49
50
  try {
51
+ const tileSelection = annotation.tileSelection;
50
52
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
51
53
  const projectId = annotation.projectId;
52
54
  const wsimageFilename = annotation.wsimage;
53
- const coords = JSON.stringify(annotation.coordinates ?? []);
54
- const status = 1;
55
+ const coords = JSON.stringify(tileSelection.zoomCoordinates ?? []);
56
+ const flag = tileSelection.flag;
55
57
  const classId = annotation.classId;
58
+ const isAnnotation = checkSelectionType(tileSelection, SelectionPrefixes.Annotation);
59
+ const isPrediction = checkSelectionType(tileSelection, SelectionPrefixes.Prediction);
60
+ if (!isAnnotation && !isPrediction) {
61
+ return {
62
+ status: "error",
63
+ error: `Invalid tileSelection id "${tileSelection.id}". Must start with "${SelectionPrefixes.Annotation}" or "${SelectionPrefixes.Prediction}".`
64
+ };
65
+ }
56
66
  if (projectId == null || wsimageFilename == null) {
57
67
  return {
58
68
  status: "error",
@@ -75,41 +85,72 @@ function validateQuery(ds, connection) {
75
85
  };
76
86
  }
77
87
  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
- }
99
- const insertSql = `
100
- INSERT INTO project_annotations (
101
- project_id, user_id, coordinates, timestamp, status, class_id, image_id
102
- ) VALUES (?, ?, ?, ?, ?, ?, ?)
103
- `;
104
- const insertStmt = connection.prepare(insertSql);
105
- const userRow = connection.prepare(
106
- `SELECT id
88
+ connection.prepare(
89
+ `DELETE FROM project_flagged_annotations
90
+ WHERE project_id = ?
91
+ AND image_id = ?
92
+ AND coordinates = ?`
93
+ ).run(projectId, imageId, coords);
94
+ connection.prepare(
95
+ `DELETE FROM project_flagged_predictions
96
+ WHERE project_id = ?
97
+ AND image_id = ?
98
+ AND coordinates = ?`
99
+ ).run(projectId, imageId, coords);
100
+ connection.prepare(
101
+ `DELETE FROM project_annotations
102
+ WHERE project_id = ?
103
+ AND image_id = ?
104
+ AND coordinates = ?`
105
+ ).run(projectId, imageId, coords);
106
+ if (isAnnotation) {
107
+ const userRow = connection.prepare(
108
+ `SELECT id
107
109
  FROM project_users
108
110
  ORDER BY id
109
111
  LIMIT 1`
110
- ).get();
111
- const userId = userRow?.id;
112
- insertStmt.run(projectId, userId, coords, timestamp, status, classId, imageId);
112
+ ).get();
113
+ const userId = userRow?.id;
114
+ if (userId === void 0) {
115
+ return {
116
+ status: "error",
117
+ error: "No users found in project_users table."
118
+ };
119
+ }
120
+ if (tileSelection.flag === FlagStatus.Normal) {
121
+ connection.prepare(
122
+ `
123
+ INSERT INTO project_annotations (
124
+ project_id, user_id, coordinates, timestamp,class_id, image_id
125
+ ) VALUES (?, ?, ?, ?, ?, ?)
126
+ `
127
+ ).run(projectId, userId, coords, timestamp, classId, imageId);
128
+ } else {
129
+ connection.prepare(
130
+ `
131
+ INSERT INTO project_flagged_annotations (
132
+ project_id, user_id, coordinates, timestamp, flagged,class_id, image_id
133
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
134
+ `
135
+ ).run(projectId, userId, coords, timestamp, flag, classId, imageId);
136
+ }
137
+ } else if (isPrediction) {
138
+ if (tileSelection.flag !== FlagStatus.Normal) {
139
+ const insertSql = `
140
+ INSERT INTO project_flagged_predictions (project_id, prediction_class_id, coordinates, flag_type,image_id,timestamp)
141
+ VALUES (?, ?, ?, ?,?,?)
142
+ `;
143
+ const insertStmt = connection.prepare(insertSql);
144
+ insertStmt.run(
145
+ annotation.projectId,
146
+ annotation.classId,
147
+ JSON.stringify(tileSelection.zoomCoordinates),
148
+ tileSelection.flag,
149
+ imageId,
150
+ timestamp
151
+ );
152
+ }
153
+ }
113
154
  return { status: "ok" };
114
155
  } catch (error) {
115
156
  console.error("Error saving annotation:", error);