@sjcrh/proteinpaint-rust 2.185.0 → 2.186.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 +73 -12
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.185.0",
2
+ "version": "2.186.0",
3
3
  "name": "@sjcrh/proteinpaint-rust",
4
4
  "type": "module",
5
5
  "description": "Rust-based utilities for proteinpaint",
package/src/volcano.rs CHANGED
@@ -40,10 +40,21 @@ struct Input {
40
40
 
41
41
  #[derive(Serialize)]
42
42
  struct PlotExtent {
43
+ /// Padded data extents — used to position overlay dots so points near the
44
+ /// real-data edge stay fully visible (mirror of manhattan's yPlot domain).
43
45
  x_min: f64,
44
46
  x_max: f64,
45
47
  y_min: f64,
46
48
  y_max: f64,
49
+ /// Unpadded data extents — used for the visible axis labels/ticks so the
50
+ /// axis only spans the real data region (mirror of manhattan's yAxisScale).
51
+ x_min_unpadded: f64,
52
+ x_max_unpadded: f64,
53
+ y_min_unpadded: f64,
54
+ y_max_unpadded: f64,
55
+ /// Dot radius in pixels (echoed back so the client can size overlay rings
56
+ /// to match the PNG without recomputing the heuristic).
57
+ dot_radius_px: f64,
47
58
  pixel_width: u32,
48
59
  pixel_height: u32,
49
60
  /// Inner drawing rect inside the PNG. Client overlay circles are
@@ -145,19 +156,45 @@ fn main() -> Result<(), Box<dyn Error>> {
145
156
  y_max_data = y_max_data.max(pt.y);
146
157
  }
147
158
 
148
- // Axis extents — symmetric on x, padded 5%.
149
- let x_span = if x_abs_max > 0.0 { x_abs_max * 1.05 } else { 1.0 };
150
- let (x_min, x_max, y_min) = (-x_span, x_span, 0f64);
151
- let y_max = if y_max_data > 0.0 { y_max_data * 1.05 } else { 1.0 };
159
+ // Unpadded axis extents — symmetric on x, raw data bounds. The dot-radius
160
+ // pad below provides pixel-level headroom so we don't need extra data-range
161
+ // breathing room (mirrors manhattan_plot.rs's tighter feel). Fallback to 1.0
162
+ // when the data has zero spread to keep the chart range valid.
163
+ let x_span = if x_abs_max > 0.0 { x_abs_max } else { 1.0 };
164
+ let (x_min_unpadded, x_max_unpadded) = (-x_span, x_span);
165
+ let y_min_unpadded = 0f64;
166
+ let y_max_unpadded = if y_max_data > 0.0 { y_max_data } else { 1.0 };
152
167
 
153
- // Render borderless scatter. No axes/labels/margins. The client owns
154
- // axes and positions the PNG exactly over its plot rect, so the inner
155
- // drawing area fills the whole canvas.
156
- let (w, h) = (input.pixel_width, input.pixel_height);
168
+ // Normalize the dot radius once into the integer pixel count plotters will
169
+ // actually draw, with a min of 1 so sub-pixel inputs don't collapse to a
170
+ // zero-radius dot. This single value drives both PNG padding and circle
171
+ // rendering, keeping the geometry self-consistent (matches manhattan).
172
+ let radius_px = (input.dot_radius as i32).max(1);
173
+ // Pad PNG by 2*radius_px so dots near the data edges stay fully visible.
174
+ let pad_px = (2 * radius_px) as u32;
175
+ let (w, h) = (input.pixel_width + pad_px, input.pixel_height + pad_px);
157
176
  if w == 0 || h == 0 || w > 4000 || h > 4000 {
158
177
  return Err(format!("pixel dimensions {}x{} out of range (1–4000)", w, h).into());
159
178
  }
179
+
180
+ // Convert pixel padding to data units using the unpadded extents and the
181
+ // unpadded pixel dimensions. Per-axis pad in data space = radius_px * (data
182
+ // range / pixel range) — keeps the data/pixel ratio identical between
183
+ // padded and unpadded space.
184
+ let x_data_per_px = (x_max_unpadded - x_min_unpadded) / input.pixel_width as f64;
185
+ let y_data_per_px = (y_max_unpadded - y_min_unpadded) / input.pixel_height as f64;
186
+ let x_pad_data = radius_px as f64 * x_data_per_px;
187
+ let y_pad_data = radius_px as f64 * y_data_per_px;
188
+ let x_min = x_min_unpadded - x_pad_data;
189
+ let x_max = x_max_unpadded + x_pad_data;
190
+ let y_min = y_min_unpadded - y_pad_data;
191
+ let y_max = y_max_unpadded + y_pad_data;
160
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());
161
198
  {
162
199
  let backend = BitMapBackend::with_buffer(&mut buffer, (w, h));
163
200
  let root = backend.into_drawing_area();
@@ -191,20 +228,26 @@ fn main() -> Result<(), Box<dyn Error>> {
191
228
  filled: false,
192
229
  stroke_width: 1,
193
230
  };
194
- let radius = input.dot_radius as i32;
195
231
 
196
232
  // Draw non-significant first so significant rings overlay on top.
197
233
  chart.draw_series(
198
234
  points
199
235
  .iter()
200
236
  .filter(|p| !p.significant)
201
- .map(|p| Circle::new((p.fc, p.y), radius, ring(color_non))),
237
+ .map(|p| Circle::new((p.fc, p.y), radius_px, ring(color_non))),
202
238
  )?;
203
239
  chart.draw_series(points.iter().filter(|p| p.significant).map(|p| {
204
240
  let c = if p.fc > 0.0 { color_up } else { color_down };
205
- Circle::new((p.fc, p.y), radius, ring(c))
241
+ Circle::new((p.fc, p.y), radius_px, ring(c))
206
242
  }))?;
207
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
+ for p in points.iter() {
247
+ let (px, py) = chart.backend_coord(&(p.fc, p.y));
248
+ all_pixel_coords.push((px as f64, py as f64));
249
+ }
250
+
208
251
  root.present()?;
209
252
  }
210
253
 
@@ -216,7 +259,18 @@ fn main() -> Result<(), Box<dyn Error>> {
216
259
  if let Some(cap) = input.max_interactive_dots {
217
260
  sig_points.truncate(cap);
218
261
  }
219
- let dots: Vec<Value> = sig_points.iter().map(|p| input.rows[p.idx].clone()).collect();
262
+ let dots: Vec<Value> = sig_points
263
+ .iter()
264
+ .map(|p| {
265
+ let mut row = input.rows[p.idx].clone();
266
+ let (px, py) = all_pixel_coords[p.idx];
267
+ if let Value::Object(ref mut m) = row {
268
+ m.insert("pixel_x".to_string(), Value::from(px));
269
+ m.insert("pixel_y".to_string(), Value::from(py));
270
+ }
271
+ row
272
+ })
273
+ .collect();
220
274
 
221
275
  let output = Output {
222
276
  png: BASE64.encode(&encode_rgb_to_png(&buffer, w, h)?),
@@ -225,6 +279,13 @@ fn main() -> Result<(), Box<dyn Error>> {
225
279
  x_max,
226
280
  y_min,
227
281
  y_max,
282
+ x_min_unpadded,
283
+ x_max_unpadded,
284
+ y_min_unpadded,
285
+ y_max_unpadded,
286
+ // Echo the normalized integer radius plotters actually drew so the
287
+ // SVG overlay sizes its rings to match the rasterized PNG dots.
288
+ dot_radius_px: radius_px as f64,
228
289
  pixel_width: w,
229
290
  pixel_height: h,
230
291
  plot_left: 0,