@sjcrh/proteinpaint-rust 2.188.0 → 2.189.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/volcano.rs +89 -49
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.188.0",
2
+ "version": "2.189.0",
3
3
  "name": "@sjcrh/proteinpaint-rust",
4
4
  "type": "module",
5
5
  "description": "Rust-based utilities for proteinpaint",
package/src/volcano.rs CHANGED
@@ -8,11 +8,11 @@
8
8
 
9
9
  use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
10
10
  use plotters::prelude::*;
11
- use plotters::style::ShapeStyle;
12
11
  use serde::{Deserialize, Serialize};
13
12
  use serde_json::Value;
14
13
  use std::error::Error;
15
14
  use std::io::{self, Read};
15
+ use tiny_skia::{Paint, PathBuilder, Pixmap, Stroke, Transform};
16
16
 
17
17
  #[derive(Deserialize)]
18
18
  struct Input {
@@ -36,6 +36,14 @@ struct Input {
36
36
  /// only the overlay list is truncated to the most-significant N.
37
37
  #[serde(default)]
38
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>,
39
47
  }
40
48
 
41
49
  #[derive(Serialize)]
@@ -189,14 +197,23 @@ fn main() -> Result<(), Box<dyn Error>> {
189
197
  let x_max = x_max_unpadded + x_pad_data;
190
198
  let y_min = y_min_unpadded - y_pad_data;
191
199
  let y_max = y_max_unpadded + y_pad_data;
192
- let mut buffer = vec![0u8; (w as usize) * (h as usize) * 3];
193
- // Per-point pixel coords as plotters actually rasterizes them. Returned to
194
- // the client so the SVG overlay rings sit exactly on top of the PNG dots
195
- // instead of being recomputed from data coords (which loses sub-pixel
196
- // precision under plotters' integer truncation).
197
- let mut all_pixel_coords: Vec<(f64, f64)> = Vec::with_capacity(points.len());
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());
198
215
  {
199
- let backend = BitMapBackend::with_buffer(&mut buffer, (w, h));
216
+ let backend = BitMapBackend::with_buffer(&mut buffer, (w_hd, h_hd));
200
217
  let root = backend.into_drawing_area();
201
218
  root.fill(&WHITE)?;
202
219
 
@@ -213,44 +230,77 @@ fn main() -> Result<(), Box<dyn Error>> {
213
230
 
214
231
  // Threshold guide lines are drawn by the SVG overlay on the client, not
215
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.
216
236
 
217
- // Resolve colors once. Up/down fall back to `color_sig` when absent.
218
- let color_sig = rgb(&input.color_significant, (214, 39, 40));
219
- let color_non = rgb(&input.color_nonsignificant, (0, 0, 0));
220
- let resolve = |o: &Option<String>| o.as_deref().map(|s| rgb(s, (214, 39, 40))).unwrap_or(color_sig);
221
- let color_up = resolve(&input.color_significant_up);
222
- let color_down = resolve(&input.color_significant_down);
223
-
224
- // Stroke-only rings at full opacity so each ring is the exact configured
225
- // group color — matching the hue the SVG overlay uses.
226
- let ring = |c: RGBColor| ShapeStyle {
227
- color: c.into(),
228
- filled: false,
229
- stroke_width: 1,
230
- };
231
-
232
- // Draw non-significant first so significant rings overlay on top.
233
- chart.draw_series(
234
- points
235
- .iter()
236
- .filter(|p| !p.significant)
237
- .map(|p| Circle::new((p.fc, p.y), radius_px, ring(color_non))),
238
- )?;
239
- chart.draw_series(points.iter().filter(|p| p.significant).map(|p| {
240
- let c = if p.fc > 0.0 { color_up } else { color_down };
241
- Circle::new((p.fc, p.y), radius_px, ring(c))
242
- }))?;
243
-
244
- // Mirror manhattan_plot.rs: capture the exact pixel coords plotters
245
- // used for each point so the client overlay can land on them precisely.
246
237
  for p in points.iter() {
247
238
  let (px, py) = chart.backend_coord(&(p.fc, p.y));
248
- all_pixel_coords.push((px as f64, py as f64));
239
+ pixel_coords_hd.push((px as f64, py as f64));
249
240
  }
250
241
 
251
242
  root.present()?;
252
243
  }
253
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
+
254
304
  // Build the interactive `dots` list: threshold-passers sorted asc by the
255
305
  // chosen p-value column, optionally capped at `max_interactive_dots`.
256
306
  let mut sig_points: Vec<&Point> = points.iter().filter(|p| p.significant).collect();
@@ -273,7 +323,7 @@ fn main() -> Result<(), Box<dyn Error>> {
273
323
  .collect();
274
324
 
275
325
  let output = Output {
276
- png: BASE64.encode(&encode_rgb_to_png(&buffer, w, h)?),
326
+ png: BASE64.encode(&pixmap.encode_png()?),
277
327
  plot_extent: PlotExtent {
278
328
  x_min,
279
329
  x_max,
@@ -302,13 +352,3 @@ fn main() -> Result<(), Box<dyn Error>> {
302
352
  println!("{}", serde_json::to_string(&output)?);
303
353
  Ok(())
304
354
  }
305
-
306
- /// Convert a plotters RGB buffer (3 bytes/px) to a PNG via tiny-skia (4 bytes/px).
307
- fn encode_rgb_to_png(rgb: &[u8], w: u32, h: u32) -> Result<Vec<u8>, Box<dyn Error>> {
308
- let mut pixmap = tiny_skia::Pixmap::new(w, h).ok_or("failed to create pixmap")?;
309
- for (src, dst) in rgb.chunks_exact(3).zip(pixmap.data_mut().chunks_exact_mut(4)) {
310
- dst[..3].copy_from_slice(src);
311
- dst[3] = 255;
312
- }
313
- Ok(pixmap.encode_png()?)
314
- }