@sjcrh/proteinpaint-rust 2.190.0 → 2.191.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.
package/Cargo.toml CHANGED
@@ -119,14 +119,6 @@ path="src/cerno.rs"
119
119
  name="readH5"
120
120
  path="src/readH5.rs"
121
121
 
122
- [[bin]]
123
- name="manhattan_plot"
124
- path="src/manhattan_plot.rs"
125
-
126
122
  [[bin]]
127
123
  name="dmrcate"
128
124
  path="src/dmrcate.rs"
129
-
130
- [[bin]]
131
- name="volcano"
132
- path="src/volcano.rs"
package/index.js CHANGED
@@ -86,7 +86,7 @@ export function run_rust(binfile, input_data, args = [], { signal } = {}) {
86
86
  // May need to add an abort signal as argument like in run_rust above.
87
87
  // Or it's likely not needed since a closed stream connection from a web browser
88
88
  // will already trigger an error. `stream_rust()` was heavily tested manually
89
- // while troubleshooting and fixing the idle rust processes the led to memory leacks
89
+ // while troubleshooting and fixing the idle rust processes the led to memory leaks
90
90
  // as part of `/gdc/mafBuild` handler code, leave this code as-is for now.
91
91
  export function stream_rust(binfile, input_data, emitJson) {
92
92
  const binpath = path.join(__dirname, '/target/release/', binfile)
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.190.0",
2
+ "version": "2.191.1",
3
3
  "name": "@sjcrh/proteinpaint-rust",
4
4
  "type": "module",
5
5
  "description": "Rust-based utilities for proteinpaint",
@@ -1,687 +0,0 @@
1
- use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
2
- use plotters::prelude::*;
3
- use plotters::style::ShapeStyle;
4
- use serde::{Deserialize, Serialize};
5
- use serde_json;
6
- use std::collections::{HashMap, HashSet};
7
- use std::convert::TryInto;
8
- use std::error::Error;
9
- use std::fs::File;
10
- use std::io::{self, BufReader};
11
- use tiny_skia::{FillRule, PathBuilder, Pixmap, Transform};
12
-
13
- // Define the JSON input structure
14
- #[derive(Deserialize, Debug)]
15
- struct Input {
16
- file: String,
17
- #[serde(rename = "type")]
18
- plot_type: String,
19
- #[serde(rename = "chrSizes")]
20
- chromosomelist: HashMap<String, u64>,
21
- plot_width: u64,
22
- plot_height: u64,
23
- device_pixel_ratio: f64,
24
- png_dot_radius: u64,
25
- max_capped_points: u64,
26
- hard_cap: f64,
27
- bin_size: f64,
28
- q_value_threshold: f64,
29
- }
30
-
31
- // chromosome info
32
- #[derive(Serialize)]
33
- struct ChromInfo {
34
- start: u64,
35
- size: u64,
36
- center: u64,
37
- }
38
-
39
- #[derive(Serialize)]
40
- struct PointDetail {
41
- x: u64,
42
- y: f64,
43
- color: String,
44
- r#type: String,
45
- gene: String,
46
- chrom: String,
47
- start: u64,
48
- end: u64,
49
- pos: u64,
50
- q_value: f64,
51
- nsubj: Option<i64>,
52
- pixel_x: f64,
53
- pixel_y: f64,
54
- }
55
-
56
- #[derive(Serialize)]
57
- struct InteractiveData {
58
- points: Vec<PointDetail>,
59
- chrom_data: HashMap<String, ChromInfo>,
60
- total_genome_length: i64,
61
- x_buffer: i64,
62
- y_min: f64,
63
- y_max: f64,
64
- device_pixel_ratio: f64,
65
- default_log_cutoff: f64,
66
- has_capped_points: bool,
67
- }
68
-
69
- #[derive(Serialize)]
70
- struct Output {
71
- png: String,
72
- plot_data: InteractiveData,
73
- }
74
-
75
- // Helper function to convert hex color to RGB
76
- fn hex_to_rgb(hex: &str) -> Option<(u8, u8, u8)> {
77
- let hex = hex.trim_start_matches('#');
78
- if hex.len() != 6 {
79
- return None;
80
- }
81
- let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
82
- let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
83
- let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
84
- Some((r, g, b))
85
- }
86
-
87
- // Helper function to calculate default log cutoff value from the data coming from GRIN2 file
88
- // We just find the mean of the -log10 q-values that are below the hard cap and
89
- // set it as the default log cutoff. If the mean is less than 40, we set it to 40.
90
- // If it is too low it can cause an error in the setting up of the histogram bins in the dynamic y-cap calculation.
91
- // The exclude_indices parameter allows us to skip placeholder values (e.g., 0.0 placeholders for zero q-values)
92
- // that would otherwise contaminate the mean calculation.
93
- fn get_log_cutoff(ys: &[f64], hard_cap: f64, exclude_indices: &HashSet<usize>) -> f64 {
94
- let filtered: Vec<f64> = ys
95
- .iter()
96
- .enumerate()
97
- .filter(|(i, &y)| y < hard_cap && !exclude_indices.contains(i))
98
- .map(|(_, &y)| y)
99
- .collect();
100
- let count = filtered.len();
101
- let sum: f64 = filtered.iter().sum();
102
-
103
- // If all values are greater than or equal to hard_cap (or excluded), default to hard_cap
104
- if filtered.is_empty() {
105
- return hard_cap;
106
- }
107
- let mean = sum / count as f64;
108
-
109
- mean.max(40.0)
110
- }
111
-
112
- /// Calculates a dynamic y-axis cap for Manhattan plots to handle outliers gracefully.
113
- ///
114
- /// # Problem
115
- /// Manhattan plots often have a few extreme outliers (very significant p-values) that
116
- /// compress the visual range for the majority of points. This function finds an optimal
117
- /// y-axis cap that:
118
- /// - Shows most data at true scale
119
- /// - Caps only a small number of extreme outliers
120
- /// - Ensures visible outliers (below hard cap) render at their true positions
121
- ///
122
- /// # Algorithm
123
- /// 1. **No outliers**: If `max_y <= default_cap`, return `max_y` (no capping needed)
124
- /// 2. **Histogram binning**: Partition the range `(default_cap, hard_cap]` into fixed-size bins
125
- /// 3. **Walk up**: Starting from the lowest bin, find the first cap where at most
126
- /// `max_capped_points` would be clamped
127
- /// 4. **Preserve visible outliers**: Ensure the chosen cap is above the highest y-value
128
- /// that falls below `hard_cap`, so those points render at their true positions
129
- ///
130
- /// # Parameters
131
- /// - `ys`: All y-values (-log10 q-values) in the plot
132
- /// - `max_capped_points`: Maximum points allowed to be clamped to the cap (e.g., 5)
133
- /// - `default_cap`: Starting threshold; points below this are never capped (e.g., whatever log_cutoff is calculated to be from get_log_cutoff)
134
- /// - `hard_cap`: Absolute maximum y-axis value; points above are always clamped (e.g., 200)
135
- /// - `bin_size`: Histogram bin width on -log10 scale (e.g., 10)
136
- ///
137
- /// # Returns
138
- /// The optimal y-axis cap, guaranteed to be in the range `[max_y.min(default_cap), hard_cap]`
139
- ///
140
- /// # Example
141
- /// With `default_cap=40`, `hard_cap=200`, `bin_size=10`, `max_capped_points=5`:
142
- /// - If 7 points are above 40, with two at 83 and 183 and five at/above 200:
143
- /// Returns 200, so the points at 83 and 183 display at their true positions while
144
- /// the 5 extreme outliers are clamped to 200
145
- fn calculate_dynamic_y_cap(
146
- ys: &[f64],
147
- max_capped_points: usize,
148
- default_cap: f64,
149
- hard_cap: f64,
150
- bin_size: f64,
151
- ) -> f64 {
152
- let mut num_bins = ((hard_cap - default_cap) / bin_size) as usize;
153
- if num_bins == 0 {
154
- // Have to make sure num_bins is positive to avoid issues with histogram later
155
- num_bins = 1;
156
- }
157
- let mut histogram = vec![0usize; num_bins];
158
- let mut max_y = f64::NEG_INFINITY;
159
- let mut max_y_below_hard_cap = f64::NEG_INFINITY; // Track highest value that's not hard-capped
160
- let mut points_above_default = 0usize;
161
-
162
- // Single pass: find max and build histogram simultaneously
163
- for &y in ys {
164
- if y > max_y {
165
- max_y = y;
166
- }
167
- if y > default_cap {
168
- points_above_default += 1;
169
- if y > hard_cap {
170
- histogram[num_bins - 1] += 1;
171
- } else {
172
- // Track the max y that's at or below the hard cap
173
- if y > max_y_below_hard_cap {
174
- max_y_below_hard_cap = y;
175
- }
176
- let bin_idx = ((y - default_cap) / bin_size) as usize;
177
- histogram[bin_idx] += 1;
178
- }
179
- }
180
- }
181
-
182
- // Case 1: No points exceed default cap - use actual max
183
- if max_y <= default_cap {
184
- return max_y;
185
- }
186
-
187
- // Walk up from default_cap to hard_cap
188
- let mut points_above = points_above_default;
189
-
190
- for (i, &count) in histogram.iter().enumerate() {
191
- if points_above <= max_capped_points {
192
- // Found acceptable number of capped points
193
- let bin_upper_bound = default_cap + ((i + 1) as f64) * bin_size;
194
-
195
- // The cap should be:
196
- // 1. At least above max_y_below_hard_cap (so those points render at true position)
197
- // 2. At most hard_cap
198
- // 3. But if all outliers are at/above hard_cap, use the bin boundary
199
- let cap = if max_y_below_hard_cap > bin_upper_bound {
200
- // There's a visible outlier above this bin - extend cap to show it
201
- (max_y_below_hard_cap + bin_size).min(hard_cap)
202
- } else {
203
- bin_upper_bound.min(hard_cap)
204
- };
205
-
206
- return cap;
207
- }
208
- points_above -= count;
209
- }
210
-
211
- // All points are hard-capped outliers
212
- hard_cap
213
- }
214
-
215
- // Function to Build cumulative chromosome map
216
- fn cumulative_chrom(
217
- chrom_size: &HashMap<String, u64>,
218
- ) -> Result<(HashMap<String, ChromInfo>, u64, Vec<String>), Box<dyn Error>> {
219
- let mut chrom_data: HashMap<String, ChromInfo> = HashMap::new();
220
- let mut cumulative_pos: u64 = 0;
221
-
222
- // Sort chromosomes
223
- let mut sorted_chroms: Vec<String> = chrom_size.keys().cloned().collect();
224
- sorted_chroms.sort_by_key(|chr| {
225
- let s = chr.trim_start_matches("chr");
226
- match s.parse::<u32>() {
227
- Ok(n) => (0, n),
228
- Err(_) => match s {
229
- "X" => (1, 23),
230
- "Y" => (1, 24),
231
- "M" | "MT" => (1, 100),
232
- _ => (2, u32::MAX),
233
- },
234
- }
235
- });
236
-
237
- for chrom in &sorted_chroms {
238
- if let Some(&size) = chrom_size.get(chrom) {
239
- chrom_data.insert(
240
- chrom.clone(),
241
- ChromInfo {
242
- start: cumulative_pos,
243
- size: size,
244
- center: cumulative_pos + size / 2,
245
- },
246
- );
247
- cumulative_pos += size;
248
- }
249
- }
250
- Ok((chrom_data, cumulative_pos, sorted_chroms))
251
- }
252
-
253
- // Envelope JSON written by the server (Grin2Envelope). Rust only needs
254
- // the geneHits rows from resultData; serde ignores other fields.
255
- #[derive(Deserialize)]
256
- struct Grin2Envelope {
257
- #[serde(rename = "resultData")]
258
- result_data: Grin2ResultData,
259
- }
260
-
261
- #[derive(Deserialize)]
262
- struct Grin2ResultData {
263
- #[serde(rename = "geneHits")]
264
- gene_hits: Vec<HashMap<String, serde_json::Value>>,
265
- }
266
-
267
- // Function to read the GRIN2 envelope JSON (grin2/{cacheid}.json) and
268
- // extract per-gene-per-mutation-type points for the Manhattan plot.
269
- fn grin2_file_read(
270
- grin2_file: &str,
271
- chrom_data: &HashMap<String, ChromInfo>,
272
- q_value_threshold: f64,
273
- ) -> Result<
274
- (
275
- Vec<u64>,
276
- Vec<f64>,
277
- Vec<String>,
278
- Vec<PointDetail>,
279
- Vec<usize>,
280
- Vec<usize>,
281
- ),
282
- Box<dyn Error>,
283
- > {
284
- // Default colours
285
- let mut colors: HashMap<String, String> = HashMap::new();
286
- colors.insert("gain".into(), "#FF4444".into());
287
- colors.insert("loss".into(), "#4444FF".into());
288
- colors.insert("mutation".into(), "#44AA44".into());
289
- colors.insert("fusion".into(), "#FFA500".into());
290
- colors.insert("sv".into(), "#9932CC".into());
291
-
292
- let mut xs = Vec::new();
293
- let mut ys = Vec::new();
294
- let mut colors_vec = Vec::new();
295
- let mut point_details = Vec::new();
296
- let mut sig_indices: Vec<usize> = Vec::new();
297
- let mut zero_q_indices: Vec<usize> = Vec::new();
298
-
299
- let f = File::open(grin2_file).expect("Failed to open grin2 envelope file");
300
- let envelope: Grin2Envelope =
301
- serde_json::from_reader(BufReader::new(f)).expect("Failed to parse grin2 envelope JSON");
302
- let gene_hits = envelope.result_data.gene_hits;
303
-
304
- let mutation_types = ["gain", "loss", "mutation", "fusion", "sv"];
305
-
306
- let mut mut_num: usize = 0;
307
- for row in &gene_hits {
308
- let chrom = match row.get("chrom").and_then(|v| v.as_str()) {
309
- Some(s) if !s.is_empty() => s,
310
- _ => continue,
311
- };
312
- let chrom_info = match chrom_data.get(chrom) {
313
- Some(info) => info,
314
- None => continue,
315
- };
316
- let gene_name = row.get("gene").and_then(|v| v.as_str()).unwrap_or("").to_string();
317
- let gene_start: u64 = match row.get("loc.start").and_then(|v| v.as_u64()) {
318
- Some(n) => n,
319
- None => continue,
320
- };
321
- let gene_end: u64 = match row.get("loc.end").and_then(|v| v.as_u64()) {
322
- Some(n) => n,
323
- None => continue,
324
- };
325
- let x_pos = chrom_info.start + gene_start;
326
-
327
- for mtype in &mutation_types {
328
- let q_key = format!("q.nsubj.{mtype}");
329
- let original_q_val: f64 = match row.get(&q_key).and_then(|v| v.as_f64()) {
330
- Some(v) if v >= 0.0 => v,
331
- _ => continue,
332
- };
333
-
334
- // Use a placeholder for zero q-values - these will be updated later
335
- // after we calculate the dynamic y_cap from the full dataset
336
- let neg_log10_q = if original_q_val == 0.0 {
337
- zero_q_indices.push(mut_num);
338
- 0.0 // Placeholder - will be set to y_cap later in plot_grin2_manhattan
339
- } else {
340
- -original_q_val.log10()
341
- };
342
-
343
- let n_key = format!("nsubj.{mtype}");
344
- let n_subj_count: Option<i64> = row.get(&n_key).and_then(|v| v.as_i64());
345
- let color = colors.get(*mtype).unwrap_or(&"#888888".to_string()).clone();
346
- // Add to plotting vectors
347
- xs.push(x_pos);
348
- ys.push(neg_log10_q);
349
- colors_vec.push(color.clone());
350
-
351
- // only add significant points for interactivity
352
- // We check against the original q-value here so we send back the correct values instead of the 1e-300 used for log transform
353
- if original_q_val <= q_value_threshold {
354
- point_details.push(PointDetail {
355
- x: x_pos,
356
- y: neg_log10_q,
357
- color,
358
- r#type: mtype.to_string(),
359
- gene: gene_name.clone(),
360
- chrom: chrom.to_string(),
361
- start: gene_start,
362
- end: gene_end,
363
- pos: gene_start,
364
- q_value: original_q_val,
365
- nsubj: n_subj_count,
366
- pixel_x: 0.0,
367
- pixel_y: 0.0,
368
- });
369
- sig_indices.push(mut_num);
370
- };
371
- mut_num += 1;
372
- }
373
- }
374
-
375
- Ok((xs, ys, colors_vec, point_details, sig_indices, zero_q_indices))
376
- }
377
-
378
- // Function to create the GRIN2 Manhattan plot
379
- fn plot_grin2_manhattan(
380
- grin2_result_file: String,
381
- chrom_size: HashMap<String, u64>,
382
- plot_width: u64,
383
- plot_height: u64,
384
- device_pixel_ratio: f64,
385
- png_dot_radius: u64,
386
- bin_size: f64,
387
- max_capped_points: u64,
388
- hard_cap: f64,
389
- q_value_threshold: f64,
390
- ) -> Result<(String, InteractiveData), Box<dyn Error>> {
391
- // ------------------------------------------------
392
- // 1. Build cumulative chromosome map
393
- // ------------------------------------------------
394
-
395
- let mut chrom_data: HashMap<String, ChromInfo> = HashMap::new();
396
- let mut cumulative_pos: u64 = 0;
397
- let mut sorted_chroms: Vec<String> = Vec::new();
398
-
399
- if let Ok((chr_data, cum_pos, chrom_sort)) = cumulative_chrom(&chrom_size) {
400
- chrom_data = chr_data;
401
- cumulative_pos = cum_pos;
402
- sorted_chroms = chrom_sort;
403
- };
404
- let total_genome_length: i64 = cumulative_pos.try_into().unwrap();
405
- let x_buffer = (total_genome_length as f64 * 0.005) as i64; // 0.5 % buffer
406
-
407
- // ------------------------------------------------
408
- // 2. Read file & collect points
409
- // ------------------------------------------------
410
-
411
- // Declare all data
412
- let mut xs = Vec::new();
413
- let mut ys = Vec::new();
414
- let mut colors_vec = Vec::new();
415
- let mut point_details = Vec::new();
416
- let mut sig_indices = Vec::new();
417
- let mut zero_q_indices: Vec<usize> = Vec::new();
418
-
419
- if let Ok((x, y, c, pd, si, zq)) = grin2_file_read(&grin2_result_file, &chrom_data, q_value_threshold) {
420
- xs = x;
421
- ys = y;
422
- colors_vec = c;
423
- point_details = pd;
424
- sig_indices = si;
425
- zero_q_indices = zq;
426
- }
427
-
428
- // ------------------------------------------------
429
- // 3. Calculate log_cutoff from data and update zero q-values
430
- // ------------------------------------------------
431
- // Convert zero_q_indices to HashSet for O(1) lookup when excluding placeholders
432
- let zero_q_set: HashSet<usize> = zero_q_indices.iter().cloned().collect();
433
- let log_cutoff = get_log_cutoff(&ys, hard_cap, &zero_q_set);
434
-
435
- // ------------------------------------------------
436
- // 4. Y-axis capping with dynamic cap
437
- // ------------------------------------------------
438
- let y_padding = png_dot_radius as f64;
439
- let y_min = 0.0 - y_padding;
440
-
441
- // Dynamic y-cap calculation:
442
- // - log_cutoff: the baseline cap (calculated from data mean)
443
- // - max_capped_points: maximum number of points allowed above cap before raising it
444
- // - hard_cap: absolute maximum cap regardless of data distribution
445
- // - bin_size: size of bins for histogram approach
446
- let max_capped_points = max_capped_points as usize;
447
-
448
- let y_cap = calculate_dynamic_y_cap(&ys, max_capped_points, log_cutoff, hard_cap, bin_size);
449
-
450
- let (y_max, has_capped_points) = if !ys.is_empty() {
451
- let max_y = ys.iter().cloned().fold(f64::MIN, f64::max);
452
-
453
- // has_capped_points is true if any points exceed the default cap (log_cutoff)
454
- let has_capped = max_y > log_cutoff;
455
-
456
- // Set q=0 points (currently placeholders at 0.0) to y_cap so they appear at the top
457
- for &idx in &zero_q_indices {
458
- ys[idx] = y_cap;
459
- }
460
- for p in point_details.iter_mut() {
461
- if p.q_value == 0.0 {
462
- p.y = y_cap;
463
- }
464
- }
465
-
466
- if max_y > y_cap {
467
- // Clamp values above the cap
468
- for y in ys.iter_mut() {
469
- if *y > y_cap {
470
- *y = y_cap;
471
- }
472
- }
473
- for p in point_details.iter_mut() {
474
- if p.y > y_cap {
475
- p.y = y_cap;
476
- }
477
- }
478
- (y_cap + 0.35 + y_padding, has_capped)
479
- } else {
480
- (max_y + 0.35 + y_padding, has_capped)
481
- }
482
- } else {
483
- (1.0 + y_padding, false)
484
- };
485
-
486
- // ------------------------------------------------
487
- // 4. Setup high-DPR bitmap dimensions
488
- // ------------------------------------------------
489
-
490
- let dpr = device_pixel_ratio.max(1.0);
491
-
492
- let png_width = plot_width + 2 * png_dot_radius;
493
- let png_height = plot_height + 2 * png_dot_radius;
494
-
495
- let w: u32 = ((png_width as f64) * dpr) as u32;
496
- let h: u32 = ((png_height as f64) * dpr) as u32;
497
-
498
- // Create RGB buffer for Plotters
499
- let mut buffer = vec![0u8; w as usize * h as usize * 3];
500
-
501
- // Make Plotters backend that draws into the RGB buffer (scale-aware)
502
-
503
- let mut pixel_positions: Vec<(f64, f64)> = Vec::with_capacity(xs.len());
504
- {
505
- let backend = BitMapBackend::with_buffer(&mut buffer, (w, h));
506
- let root = backend.into_drawing_area();
507
- root.fill(&WHITE)?;
508
-
509
- // ------------------------------------------------
510
- // 5. Build the chart (no axes, no margins)
511
- // ------------------------------------------------
512
- let mut chart = ChartBuilder::on(&root)
513
- .margin(0)
514
- .set_all_label_area_size(0)
515
- .build_cartesian_2d((-x_buffer)..(total_genome_length + x_buffer), y_min..y_max)?;
516
-
517
- chart
518
- .configure_mesh()
519
- .disable_x_mesh()
520
- .disable_y_mesh()
521
- .disable_axes()
522
- .draw()?;
523
-
524
- // ------------------------------------------------
525
- // 6. Alternating chromosome backgrounds
526
- // ------------------------------------------------
527
- for (i, chrom) in sorted_chroms.iter().enumerate() {
528
- if let Some(info) = chrom_data.get(chrom) {
529
- let bg = if i % 2 == 0 { WHITE } else { RGBColor(211, 211, 211) };
530
- let fill_style: ShapeStyle = bg.mix(0.5).filled();
531
- let rect = Rectangle::new(
532
- [
533
- (info.start as i64, (y_min + y_padding)),
534
- ((info.start + info.size) as i64, (y_max - y_padding)),
535
- ],
536
- fill_style,
537
- );
538
- chart.draw_series(vec![rect])?;
539
- }
540
- }
541
-
542
- // ------------------------------------------------
543
- // 7. Capture high-DPR pixel mapping for the points
544
- // we do not draw the points with plotters (will use tiny-skia for AA)
545
- // but use charts.backend_coord to map data->pixel in the high-DPR backend
546
- // ------------------------------------------------
547
-
548
- if !xs.is_empty() {
549
- for (x, y) in xs.iter().zip(ys.iter()) {
550
- // convert data coords -> high-DPR pixel coords
551
- let (px, py) = chart.backend_coord(&(*x as i64, *y));
552
- pixel_positions.push((px as f64, py as f64));
553
- }
554
- };
555
-
556
- for (i, p) in point_details.iter_mut().enumerate() {
557
- let (px, py) = pixel_positions[*&sig_indices[i]];
558
- p.pixel_x = px / dpr;
559
- p.pixel_y = py / dpr;
560
- }
561
-
562
- // flush root drawing area
563
- root.present()?;
564
- }
565
-
566
- // Convert Plotters RGB buffer into tiny-skia RGBA pixmap
567
- let mut pixmap = Pixmap::new(w, h).ok_or("Failed to create pixmap")?;
568
- {
569
- let data = pixmap.data_mut();
570
- let mut src_i = 0usize;
571
- let mut dst_i = 0usize;
572
- for _ in 0..(w as usize * h as usize) {
573
- let r = buffer[src_i];
574
- let g = buffer[src_i + 1];
575
- let b = buffer[src_i + 2];
576
- data[dst_i] = r;
577
- data[dst_i + 1] = g;
578
- data[dst_i + 2] = b;
579
- data[dst_i + 3] = 255u8; // opaque alpha
580
- src_i += 3;
581
- dst_i += 4;
582
- }
583
- }
584
-
585
- // Draw anti-aliased circles using tiny-skia into the pixmap
586
- // radius in HIGH-DPR pixels:
587
- let radius_high_dpr = (png_dot_radius as f32) * (dpr as f32);
588
-
589
- // Paint template
590
- let mut paint = tiny_skia::Paint::default();
591
-
592
- // for perfomance: reuse a PathBuilder to create circles
593
- // will create a small path per point
594
- for i in 0..xs.len() {
595
- let (px, py) = pixel_positions[i]; // pixel coordinates for this point
596
- let color_hex = &colors_vec[i];
597
-
598
- let (r_u8, g_u8, b_u8) = match hex_to_rgb(color_hex) {
599
- Some(rgb) => rgb,
600
- None => (136u8, 136u8, 136u8),
601
- };
602
- paint.set_color_rgba8(r_u8, g_u8, b_u8, 255u8);
603
- let mut pb = PathBuilder::new();
604
- pb.push_circle(px as f32, py as f32, radius_high_dpr);
605
-
606
- if let Some(path) = pb.finish() {
607
- pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
608
- };
609
- }
610
-
611
- // encode pixmap to PNG bytes
612
- let png_bytes = pixmap.encode_png()?;
613
- let png_data = BASE64.encode(&png_bytes);
614
-
615
- // ------------------------------------------------
616
- // 8. Generate interactive data
617
- // ------------------------------------------------
618
- let interactive_data = InteractiveData {
619
- points: point_details,
620
- chrom_data,
621
- total_genome_length,
622
- x_buffer,
623
- y_min,
624
- y_max,
625
- device_pixel_ratio: dpr,
626
- default_log_cutoff: log_cutoff,
627
- has_capped_points,
628
- };
629
- Ok((png_data, interactive_data))
630
- }
631
-
632
- fn main() -> Result<(), Box<dyn std::error::Error>> {
633
- let mut input = String::new();
634
- match io::stdin().read_line(&mut input) {
635
- Ok(_bytes_read) => {
636
- let input_json: Input = match serde_json::from_str(&input) {
637
- Ok(json) => json,
638
- Err(_err) => {
639
- panic!("Invalid JSON input");
640
- }
641
- };
642
-
643
- // input data type
644
- // *** might need to change the key later
645
- let input_data = &input_json.plot_type;
646
-
647
- if input_data == "grin2" {
648
- let grin2_file = &input_json.file;
649
- let chrom_size = &input_json.chromosomelist;
650
- let plot_width = &input_json.plot_width;
651
- let plot_height = &input_json.plot_height;
652
- let device_pixel_ratio = &input_json.device_pixel_ratio;
653
- let png_dot_radius = &input_json.png_dot_radius;
654
- let max_capped_points = &input_json.max_capped_points;
655
- let hard_cap = &input_json.hard_cap;
656
- let bin_size = &input_json.bin_size;
657
- let q_value_threshold = &input_json.q_value_threshold;
658
- if let Ok((base64_string, plot_data)) = plot_grin2_manhattan(
659
- grin2_file.clone(),
660
- chrom_size.clone(),
661
- plot_width.clone(),
662
- plot_height.clone(),
663
- device_pixel_ratio.clone(),
664
- png_dot_radius.clone(),
665
- bin_size.clone(),
666
- max_capped_points.clone(),
667
- hard_cap.clone(),
668
- q_value_threshold.clone(),
669
- ) {
670
- let output = Output {
671
- png: base64_string,
672
- plot_data,
673
- };
674
- if let Ok(json) = serde_json::to_string(&output) {
675
- println!("{}", json);
676
- }
677
- } else {
678
- eprintln!("Failed to generate Manhattan plot");
679
- };
680
- }
681
- }
682
- Err(_err) => {
683
- panic!("Error reading input JSON!");
684
- }
685
- }
686
- Ok(())
687
- }
package/src/volcano.rs DELETED
@@ -1,354 +0,0 @@
1
- // Server-side volcano plot renderer.
2
- //
3
- // Reads all DA rows + significance thresholds + render params on stdin (JSON),
4
- // rasterizes the full scatter to a base64 PNG, and in the same pass emits the
5
- // threshold-passing rows back sorted ascending by the chosen p-value column.
6
- // This makes the Rust pass the single source of truth for both the colored
7
- // dots in the PNG and the interactive top-significant overlay on the client.
8
-
9
- use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
10
- use plotters::prelude::*;
11
- use serde::{Deserialize, Serialize};
12
- use serde_json::Value;
13
- use std::error::Error;
14
- use std::io::{self, Read};
15
- use tiny_skia::{Paint, PathBuilder, Pixmap, Stroke, Transform};
16
-
17
- #[derive(Deserialize)]
18
- struct Input {
19
- /// DA rows; each must carry fold_change + original_p_value + adjusted_p_value.
20
- /// Route-specific extras (gene_name, promoter_id, etc.) are preserved via Value.
21
- rows: Vec<Value>,
22
- /// "adjusted" or "original" — which p-value column to threshold and sort by.
23
- p_value_type: String,
24
- /// Cutoff on the -log10 scale.
25
- p_value_cutoff: f64,
26
- /// Log2 fold-change magnitude cutoff.
27
- fold_change_cutoff: f64,
28
- pixel_width: u32,
29
- pixel_height: u32,
30
- color_significant: String,
31
- color_significant_up: Option<String>,
32
- color_significant_down: Option<String>,
33
- color_nonsignificant: String,
34
- dot_radius: f64,
35
- /// Optional cap on the returned `dots`. The PNG still shows every row;
36
- /// only the overlay list is truncated to the most-significant N.
37
- #[serde(default)]
38
- max_interactive_dots: Option<usize>,
39
- /// Hi-DPI scale factor (e.g. 2.0 on retina). Defaults to 1.0 when absent
40
- /// so existing callers don't change behavior. The PNG is rasterized at
41
- /// `(pixel_* + pad) * dpr` device pixels and is rendered at the CSS-space
42
- /// dimensions reported in `plot_extent.pixel_*` — the browser uses the
43
- /// extra resolution for sharpness on hi-DPI displays. Mirror of
44
- /// manhattan_plot.rs's `device_pixel_ratio` handling.
45
- #[serde(default)]
46
- device_pixel_ratio: Option<f64>,
47
- }
48
-
49
- #[derive(Serialize)]
50
- struct PlotExtent {
51
- /// Padded data extents — used to position overlay dots so points near the
52
- /// real-data edge stay fully visible (mirror of manhattan's yPlot domain).
53
- x_min: f64,
54
- x_max: f64,
55
- y_min: f64,
56
- y_max: f64,
57
- /// Unpadded data extents — used for the visible axis labels/ticks so the
58
- /// axis only spans the real data region (mirror of manhattan's yAxisScale).
59
- x_min_unpadded: f64,
60
- x_max_unpadded: f64,
61
- y_min_unpadded: f64,
62
- y_max_unpadded: f64,
63
- /// Dot radius in pixels (echoed back so the client can size overlay rings
64
- /// to match the PNG without recomputing the heuristic).
65
- dot_radius_px: f64,
66
- pixel_width: u32,
67
- pixel_height: u32,
68
- /// Inner drawing rect inside the PNG. Client overlay circles are
69
- /// positioned against this rect, not the full canvas.
70
- plot_left: i32,
71
- plot_top: i32,
72
- plot_right: i32,
73
- plot_bottom: i32,
74
- /// Smallest non-zero p observed; rows with p==0 were positioned at
75
- /// -log10(min_nonzero_p) so the client must reuse this cap to align.
76
- min_nonzero_p: f64,
77
- }
78
-
79
- #[derive(Serialize)]
80
- struct Output {
81
- png: String,
82
- plot_extent: PlotExtent,
83
- /// Threshold-passing rows, sorted asc by the chosen p-value column. These
84
- /// are the only rows sent back — the PNG carries every row.
85
- dots: Vec<Value>,
86
- /// Total rows rendered into the PNG; used client-side for stats.
87
- total_rows: usize,
88
- /// Total rows that passed the significance thresholds, before any
89
- /// `max_interactive_dots` truncation. Use this for "% significant" stats.
90
- total_significant_rows: usize,
91
- }
92
-
93
- fn rgb(hex: &str, fallback: (u8, u8, u8)) -> RGBColor {
94
- let h = hex.trim_start_matches('#');
95
- let parse = |i: usize| u8::from_str_radix(&h.get(i..i + 2).unwrap_or(""), 16).ok();
96
- match (h.len(), parse(0), parse(2), parse(4)) {
97
- (6, Some(r), Some(g), Some(b)) => RGBColor(r, g, b),
98
- _ => RGBColor(fallback.0, fallback.1, fallback.2),
99
- }
100
- }
101
-
102
- struct Point {
103
- idx: usize,
104
- fc: f64,
105
- /// raw p-value (used for sorting)
106
- p: f64,
107
- /// -log10(p) with p==0 capped to min_nonzero_p
108
- y: f64,
109
- significant: bool,
110
- }
111
-
112
- fn main() -> Result<(), Box<dyn Error>> {
113
- let mut buf = String::new();
114
- io::stdin().read_to_string(&mut buf)?;
115
- let input: Input = serde_json::from_str(&buf)?;
116
-
117
- let p_field = match input.p_value_type.as_str() {
118
- "adjusted" => "adjusted_p_value",
119
- "original" => "original_p_value",
120
- other => return Err(format!("invalid p_value_type: {other}").into()),
121
- };
122
-
123
- // One pass: pull numeric summaries and find the smallest non-zero p so we
124
- // can cap y for rows with p == 0 (matching the client behavior).
125
- let mut points: Vec<Point> = Vec::with_capacity(input.rows.len());
126
- let mut min_nonzero_p = f64::INFINITY;
127
- for (idx, row) in input.rows.iter().enumerate() {
128
- let fc = row
129
- .get("fold_change")
130
- .and_then(|v| v.as_f64())
131
- .ok_or_else(|| format!("row {idx} missing numeric fold_change"))?;
132
- if !fc.is_finite() {
133
- return Err(format!("row {idx} fold_change is not finite ({fc})").into());
134
- }
135
- let p = row
136
- .get(p_field)
137
- .and_then(|v| v.as_f64())
138
- .ok_or_else(|| format!("row {idx} missing numeric {p_field}"))?;
139
- if !p.is_finite() || p < 0.0 {
140
- return Err(format!("row {idx} {p_field} must be a finite value >= 0 (got {p})").into());
141
- }
142
- if p > 0.0 && p < min_nonzero_p {
143
- min_nonzero_p = p;
144
- }
145
- points.push(Point {
146
- idx,
147
- fc,
148
- p,
149
- y: 0.0,
150
- significant: false,
151
- });
152
- }
153
- if !min_nonzero_p.is_finite() {
154
- min_nonzero_p = 1e-300;
155
- }
156
-
157
- // Classify + compute y; track axis extents in the same pass.
158
- let (mut x_abs_max, mut y_max_data) = (0f64, 0f64);
159
- for pt in points.iter_mut() {
160
- let p_for_y = if pt.p <= 0.0 { min_nonzero_p } else { pt.p };
161
- pt.y = -p_for_y.log10();
162
- pt.significant = pt.y > input.p_value_cutoff && pt.fc.abs() > input.fold_change_cutoff;
163
- x_abs_max = x_abs_max.max(pt.fc.abs());
164
- y_max_data = y_max_data.max(pt.y);
165
- }
166
-
167
- // Unpadded axis extents — symmetric on x, raw data bounds. The dot-radius
168
- // pad below provides pixel-level headroom so we don't need extra data-range
169
- // breathing room (mirrors manhattan_plot.rs's tighter feel). Fallback to 1.0
170
- // when the data has zero spread to keep the chart range valid.
171
- let x_span = if x_abs_max > 0.0 { x_abs_max } else { 1.0 };
172
- let (x_min_unpadded, x_max_unpadded) = (-x_span, x_span);
173
- let y_min_unpadded = 0f64;
174
- let y_max_unpadded = if y_max_data > 0.0 { y_max_data } else { 1.0 };
175
-
176
- // Normalize the dot radius once into the integer pixel count plotters will
177
- // actually draw, with a min of 1 so sub-pixel inputs don't collapse to a
178
- // zero-radius dot. This single value drives both PNG padding and circle
179
- // rendering, keeping the geometry self-consistent (matches manhattan).
180
- let radius_px = (input.dot_radius as i32).max(1);
181
- // Pad PNG by 2*radius_px so dots near the data edges stay fully visible.
182
- let pad_px = (2 * radius_px) as u32;
183
- let (w, h) = (input.pixel_width + pad_px, input.pixel_height + pad_px);
184
- if w == 0 || h == 0 || w > 4000 || h > 4000 {
185
- return Err(format!("pixel dimensions {}x{} out of range (1–4000)", w, h).into());
186
- }
187
-
188
- // Convert pixel padding to data units using the unpadded extents and the
189
- // unpadded pixel dimensions. Per-axis pad in data space = radius_px * (data
190
- // range / pixel range) — keeps the data/pixel ratio identical between
191
- // padded and unpadded space.
192
- let x_data_per_px = (x_max_unpadded - x_min_unpadded) / input.pixel_width as f64;
193
- let y_data_per_px = (y_max_unpadded - y_min_unpadded) / input.pixel_height as f64;
194
- let x_pad_data = radius_px as f64 * x_data_per_px;
195
- let y_pad_data = radius_px as f64 * y_data_per_px;
196
- let x_min = x_min_unpadded - x_pad_data;
197
- let x_max = x_max_unpadded + x_pad_data;
198
- let y_min = y_min_unpadded - y_pad_data;
199
- let y_max = y_max_unpadded + y_pad_data;
200
-
201
- // Hi-DPR scaling. The buffer/chart are sized in device pixels (CSS * dpr)
202
- // and the drawn radius/stroke are scaled the same way, so the PNG is
203
- // sharper on retina. backend_coord returns device-pixel coords; we divide
204
- // by dpr below to keep `pixel_x/pixel_y` in CSS-space (which is what the
205
- // SVG overlay coordinate system uses). Mirror of manhattan_plot.rs.
206
- let dpr = input.device_pixel_ratio.unwrap_or(1.0).max(1.0);
207
- let w_hd = ((w as f64) * dpr) as u32;
208
- let h_hd = ((h as f64) * dpr) as u32;
209
-
210
- let mut buffer = vec![0u8; (w_hd as usize) * (h_hd as usize) * 3];
211
- // Per-point pixel coords as plotters' chart maps them. Captured in device
212
- // pixels so tiny-skia can draw the AA rings exactly at those positions;
213
- // we keep a CSS-space copy below for the SVG overlay.
214
- let mut pixel_coords_hd: Vec<(f64, f64)> = Vec::with_capacity(points.len());
215
- {
216
- let backend = BitMapBackend::with_buffer(&mut buffer, (w_hd, h_hd));
217
- let root = backend.into_drawing_area();
218
- root.fill(&WHITE)?;
219
-
220
- let mut chart = ChartBuilder::on(&root)
221
- .margin(0)
222
- .set_all_label_area_size(0)
223
- .build_cartesian_2d(x_min..x_max, y_min..y_max)?;
224
- chart
225
- .configure_mesh()
226
- .disable_x_mesh()
227
- .disable_y_mesh()
228
- .disable_axes()
229
- .draw()?;
230
-
231
- // Threshold guide lines are drawn by the SVG overlay on the client, not
232
- // here — double-drawing them would add stray lines offset by axis padding.
233
- // The dots themselves are drawn below with tiny-skia for true AA; here
234
- // plotters just gives us a white-background buffer and the data-to-pixel
235
- // mapping. Mirror of manhattan_plot.rs.
236
-
237
- for p in points.iter() {
238
- let (px, py) = chart.backend_coord(&(p.fc, p.y));
239
- pixel_coords_hd.push((px as f64, py as f64));
240
- }
241
-
242
- root.present()?;
243
- }
244
-
245
- // Convert plotters' RGB buffer to a tiny-skia RGBA pixmap, then stroke the
246
- // dots on top with anti-aliasing — gives crisp rings even when the user
247
- // zooms in past native DPR. Plotters' BitMapBackend has no AA on shapes,
248
- // which is why ring edges looked chunky before this rewrite.
249
- let mut pixmap = Pixmap::new(w_hd, h_hd).ok_or("failed to create pixmap")?;
250
- {
251
- let data = pixmap.data_mut();
252
- for (src, dst) in buffer.chunks_exact(3).zip(data.chunks_exact_mut(4)) {
253
- dst[..3].copy_from_slice(src);
254
- dst[3] = 255;
255
- }
256
- }
257
-
258
- // Resolve colors once. Up/down fall back to `color_sig` when absent.
259
- let color_sig = rgb(&input.color_significant, (214, 39, 40));
260
- let color_non = rgb(&input.color_nonsignificant, (0, 0, 0));
261
- let resolve = |o: &Option<String>| o.as_deref().map(|s| rgb(s, (214, 39, 40))).unwrap_or(color_sig);
262
- let color_up = resolve(&input.color_significant_up);
263
- let color_down = resolve(&input.color_significant_down);
264
-
265
- let radius_hd_f = radius_px as f32 * dpr as f32;
266
- // 1 CSS-pixel-wide stroke at hi-DPR. The stroke straddles the path, so the
267
- // visible ring thickness is `stroke_width` device px ≈ 1 CSS px.
268
- let mut stroke = Stroke::default();
269
- stroke.width = dpr as f32;
270
- let mut paint = Paint::default();
271
- paint.anti_alias = true;
272
-
273
- let stroke_ring = |pixmap: &mut Pixmap, paint: &mut Paint, color: RGBColor, px: f32, py: f32| {
274
- paint.set_color_rgba8(color.0, color.1, color.2, 255);
275
- let mut pb = PathBuilder::new();
276
- pb.push_circle(px, py, radius_hd_f);
277
- if let Some(path) = pb.finish() {
278
- pixmap.stroke_path(&path, paint, &stroke, Transform::identity(), None);
279
- }
280
- };
281
-
282
- // Draw non-significant first so significant rings overlay on top.
283
- for (i, p) in points.iter().enumerate() {
284
- if p.significant {
285
- continue;
286
- }
287
- let (px, py) = pixel_coords_hd[i];
288
- stroke_ring(&mut pixmap, &mut paint, color_non, px as f32, py as f32);
289
- }
290
- for (i, p) in points.iter().enumerate() {
291
- if !p.significant {
292
- continue;
293
- }
294
- let (px, py) = pixel_coords_hd[i];
295
- let c = if p.fc > 0.0 { color_up } else { color_down };
296
- stroke_ring(&mut pixmap, &mut paint, c, px as f32, py as f32);
297
- }
298
-
299
- // CSS-space coords for the SVG overlay — divide the device-pixel positions
300
- // by dpr. The overlay does not know about hi-DPR; the PNG sizing handles
301
- // sharpness for us.
302
- let all_pixel_coords: Vec<(f64, f64)> = pixel_coords_hd.iter().map(|(x, y)| (x / dpr, y / dpr)).collect();
303
-
304
- // Build the interactive `dots` list: threshold-passers sorted asc by the
305
- // chosen p-value column, optionally capped at `max_interactive_dots`.
306
- let mut sig_points: Vec<&Point> = points.iter().filter(|p| p.significant).collect();
307
- sig_points.sort_by(|a, b| a.p.partial_cmp(&b.p).unwrap_or(std::cmp::Ordering::Equal));
308
- let total_significant_rows = sig_points.len();
309
- if let Some(cap) = input.max_interactive_dots {
310
- sig_points.truncate(cap);
311
- }
312
- let dots: Vec<Value> = sig_points
313
- .iter()
314
- .map(|p| {
315
- let mut row = input.rows[p.idx].clone();
316
- let (px, py) = all_pixel_coords[p.idx];
317
- if let Value::Object(ref mut m) = row {
318
- m.insert("pixel_x".to_string(), Value::from(px));
319
- m.insert("pixel_y".to_string(), Value::from(py));
320
- }
321
- row
322
- })
323
- .collect();
324
-
325
- let output = Output {
326
- png: BASE64.encode(&pixmap.encode_png()?),
327
- plot_extent: PlotExtent {
328
- x_min,
329
- x_max,
330
- y_min,
331
- y_max,
332
- x_min_unpadded,
333
- x_max_unpadded,
334
- y_min_unpadded,
335
- y_max_unpadded,
336
- // Echo the normalized integer radius plotters actually drew so the
337
- // SVG overlay sizes its rings to match the rasterized PNG dots.
338
- dot_radius_px: radius_px as f64,
339
- pixel_width: w,
340
- pixel_height: h,
341
- plot_left: 0,
342
- plot_top: 0,
343
- plot_right: w as i32,
344
- plot_bottom: h as i32,
345
- min_nonzero_p,
346
- },
347
- dots,
348
- total_rows: input.rows.len(),
349
- total_significant_rows,
350
- };
351
-
352
- println!("{}", serde_json::to_string(&output)?);
353
- Ok(())
354
- }