@sjcrh/proteinpaint-rust 2.189.0 → 2.191.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/Cargo.toml +0 -17
- package/package.json +1 -1
- package/src/aichatbot.rs +0 -1554
- package/src/manhattan_plot.rs +0 -725
- package/src/query_classification.rs +0 -152
- package/src/summary_agent.rs +0 -201
- package/src/test_ai.rs +0 -193
- package/src/volcano.rs +0 -354
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
|
-
}
|