use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use dspk_ops::math::percentile;
use dspk_core::SignalUnit;
use dspk_view::{min_max_decimate_into, Envelope, SignalBackend, View};
use crate::ui::plot::canvas::{Canvas, Frame};
use crate::ui::plot::ticks::nice_step;
use crate::ui::theme::palette::Palette;
use crate::data::recording::SpikeEventStore;
pub const NOMINAL_VOLTS: f64 = 80e-6;
pub const NOMINAL_OTHER: f32 = 1.0;
const LANE_FILL: f32 = 0.84;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub enum TimeViewKind {
#[default]
Traces,
Heatmap,
Raster,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub enum ColorBy {
#[default]
Channel,
Single,
Amplitude,
}
impl ColorBy {
pub const ALL: [ColorBy; 3] = [ColorBy::Channel, ColorBy::Single, ColorBy::Amplitude];
pub fn name(self) -> &'static str {
match self {
ColorBy::Channel => "By channel",
ColorBy::Single => "One colour",
ColorBy::Amplitude => "By amplitude",
}
}
}
#[derive(Clone)]
pub struct RenderRequest {
pub signal: Arc<dyn SignalBackend>,
pub events: Arc<SpikeEventStore>,
pub width: u32,
pub height: u32,
pub scale: f32,
pub mode: TimeViewKind,
pub channels: Vec<usize>,
pub window_start_sec: f64,
pub window_sec: f64,
pub start_time_sec: f64,
pub amplitude_scale: f32,
pub nominal: f32,
pub auto_scale: bool,
pub remove_dc: bool,
pub scale_hint: f32,
pub grid_times: Vec<f64>,
pub scale_bar: bool,
pub highlights: Vec<(crate::ui::plot::canvas::Pixel, Vec<(f64, usize)>)>,
pub overlay: Option<Arc<crate::plugin::TraceOverlay>>,
pub raster: Option<Arc<crate::plugin::Raster>>,
pub color_by: ColorBy,
pub palette: Palette,
}
thread_local! {
static RENDERER: std::cell::RefCell<WaveformRenderer> = std::cell::RefCell::new(WaveformRenderer::default());
}
pub fn render_on_worker(req: &RenderRequest, cancel: &AtomicBool) -> Option<(Frame, f32, bool)> {
if cancel.load(Ordering::Relaxed) {
return None;
}
Some(RENDERER.with(|r| {
let mut r = r.borrow_mut();
let (frame, scale) = r.render_scaled(req);
(frame, scale, r.complete)
}))
}
fn visible(req: &RenderRequest) -> Option<(usize, usize, usize, usize)> {
let info = req.signal.info();
let samples = info.samples as usize;
if samples == 0 || info.channel_count() == 0 || req.window_sec <= 0.0 {
return None;
}
let sr = info.sample_rate_hz();
let s0 = (req.window_start_sec - req.start_time_sec) * sr;
let s1 = s0 + req.window_sec * sr;
let (a, b) = (s0.max(0.0), s1.min(samples as f64));
let w_px = req.width.max(1) as f64;
let (x0, x1) = if b > a { (((a - s0) / (s1 - s0) * w_px).round() as usize, ((b - s0) / (s1 - s0) * w_px).round() as usize) } else { (0, 0) };
let (start, end) = (a.round() as usize, (b.round() as usize).max(a.round() as usize));
Some((start, end, x0, x1.min(req.width.max(1) as usize)))
}
pub fn nominal_amplitude(unit: &SignalUnit) -> f32 {
let volts_per_unit = match unit {
SignalUnit::Volt => 1.0,
SignalUnit::Millivolt => 1e-3,
SignalUnit::Microvolt => 1e-6,
_ => return NOMINAL_OTHER,
};
(NOMINAL_VOLTS / volts_per_unit) as f32
}
pub fn px_per_unit(lane_h: f32, amplitude_scale: f32, nominal: f32) -> f32 {
lane_h * 0.5 * LANE_FILL * amplitude_scale / nominal.max(f32::MIN_POSITIVE)
}
pub fn scale_bar_value(lane_h: f32, k: f32) -> f32 {
if k <= 0.0 || lane_h <= 0.0 {
return 0.0;
}
nice_step((0.45 * lane_h / k) as f64) as f32
}
fn choose_scale(amplitude: f32, hint: f32, nominal: f32) -> f32 {
if amplitude.is_nan() || amplitude <= 0.0 || !amplitude.is_finite() {
return if hint > 0.0 { hint } else { nominal };
}
if hint > 0.0 && amplitude >= 0.5 * hint && amplitude <= 1.25 * hint { hint } else { amplitude }
}
fn draw_raster(canvas: &mut Canvas, req: &RenderRequest) {
let Some(r) = &req.raster else { return };
let n = r.rows.len();
if n == 0 {
return;
}
let row_h = canvas.height as f32 / n as f32;
let w = canvas.width as f64;
let tick = (row_h * 0.8).max(1.0);
for (i, row) in r.rows.iter().enumerate() {
let top = i as f32 * row_h + (row_h - tick) * 0.5;
for &t in &row.times {
let x = ((t - req.window_start_sec) / req.window_sec * w) as f32;
if x >= 0.0 && x < w as f32 {
canvas.rect(x, top, x + req.scale.max(1.0), top + tick, row.color, 1.0);
}
}
}
}
const DIM_ALPHA: f32 = 0.35;
fn column_span(row: &[[f32; 2]], x: usize, center: f32, k: f32, (lo, hi): (f32, f32)) -> Option<(f32, f32)> {
let [mn, mx] = *row.get(x)?;
if !mn.is_finite() {
return None;
}
let (mut a, mut b) = ((center - mx * k).clamp(lo, hi), (center - mn * k).clamp(lo, hi));
if let Some(&[pmn, pmx]) = x.checked_sub(1).and_then(|p| row.get(p)) {
if pmn.is_finite() {
let (pa, pb) = ((center - pmx * k).clamp(lo, hi), (center - pmn * k).clamp(lo, hi));
a = a.min(pb);
b = b.max(pa);
}
}
Some((a, b))
}
#[derive(Default)]
pub struct WaveformRenderer {
env: Vec<[f32; 2]>,
valid: Vec<bool>,
read_channels: Vec<usize>,
read_rows: Vec<usize>,
answer: Option<Envelope>,
complete: bool,
heat: Vec<f32>,
stats: Vec<f32>,
}
impl WaveformRenderer {
#[cfg(test)]
pub fn render(&mut self, req: &RenderRequest) -> Frame {
self.render_scaled(req).0
}
pub fn render_scaled(&mut self, req: &RenderRequest) -> (Frame, f32) {
let signal = req.signal.as_ref();
let width = req.width.max(1);
let height = req.height.max(1);
let mut pixel_buffer = Frame::new(width, height);
let mut canvas = pixel_buffer.canvas();
canvas.pixels.fill(req.palette.background);
let Some((start, end, x0, x1)) = visible(req) else {
return (pixel_buffer, req.scale_hint.max(0.0));
};
let w = canvas.width as f64;
for &t in &req.grid_times {
let x = ((t - req.window_start_sec) / req.window_sec * w).round();
if x >= 0.0 && x < w {
canvas.vline(x as usize, 0, canvas.height, req.palette.grid);
}
}
if req.mode == TimeViewKind::Raster {
self.complete = true;
draw_raster(&mut canvas, req);
return (pixel_buffer, req.scale_hint.max(0.0));
}
self.envelope(signal, &req.channels, start, end, x1.saturating_sub(x0));
self.place_columns(req.channels.len(), x0, x1, canvas.width);
let scale = self.adjust(req, canvas.width);
let has_samples = end > start;
match req.mode {
TimeViewKind::Traces => self.draw_traces(&mut canvas, &req.events, req, has_samples, scale),
TimeViewKind::Heatmap => self.draw_heatmap(&mut canvas, req, has_samples, scale),
TimeViewKind::Raster => {}
}
(pixel_buffer, scale)
}
fn place_columns(&mut self, rows: usize, x0: usize, x1: usize, width: usize) {
let cols = x1.saturating_sub(x0);
if cols == width {
return;
}
let mut full = vec![[f32::NAN, f32::NAN]; rows * width];
if cols > 0 {
for r in 0..rows {
full[r * width + x0..r * width + x1].copy_from_slice(&self.env[r * cols..(r + 1) * cols]);
}
}
self.env = full;
}
fn adjust(&mut self, req: &RenderRequest, width: usize) -> f32 {
let rows = req.channels.len();
if req.remove_dc {
for r in (0..rows).filter(|&r| self.valid[r]) {
let row = &mut self.env[r * width..(r + 1) * width];
self.stats.clear();
self.stats.extend(row.iter().filter(|v| v[0].is_finite()).map(|v| 0.5 * (v[0] + v[1])));
let (Some(q1), Some(q3)) = (percentile(&mut self.stats, 25.0), percentile(&mut self.stats, 75.0)) else { continue };
let offset = 0.5 * (q1 + q3);
for v in row.iter_mut() {
v[0] -= offset;
v[1] -= offset;
}
}
}
if !req.auto_scale {
return req.nominal;
}
self.stats.clear();
for r in (0..rows).filter(|&r| self.valid[r]) {
self.stats.extend(self.env[r * width..(r + 1) * width].iter().filter(|v| v[0].is_finite()).map(|v| v[0].abs().max(v[1].abs())));
}
choose_scale(percentile(&mut self.stats, 99.0).unwrap_or(0.0), req.scale_hint, req.nominal)
}
fn envelope(&mut self, signal: &dyn SignalBackend, rows: &[usize], start: usize, end: usize, width: usize) {
let total = signal.info().channel_count();
self.complete = true;
self.env.clear();
self.env.resize(rows.len() * width, [f32::NAN, f32::NAN]);
self.valid.clear();
self.valid.extend(rows.iter().map(|&c| c < total));
self.read_channels.clear();
self.read_rows.clear();
for (r, &c) in rows.iter().enumerate().filter(|&(_, &c)| c < total) {
self.read_channels.push(c);
self.read_rows.push(r);
}
if self.read_channels.is_empty() || end <= start || width == 0 {
return;
}
let view = View { channels: self.read_channels.clone(), start: start as u64, end: end as u64, width };
let answer = self.answer.get_or_insert_with(|| Envelope::Samples(Vec::new()));
if let Err(e) = signal.view(&view, answer) {
tracing::warn!("cannot draw samples {start}..{end}: {e}");
return;
}
match answer {
Envelope::Columns { values, complete } => {
self.complete = *complete;
for (k, &r) in self.read_rows.iter().enumerate() {
self.env[r * width..(r + 1) * width].copy_from_slice(&values[k * width..(k + 1) * width]);
}
}
Envelope::Samples(samples) => {
let n = end - start;
for (k, &r) in self.read_rows.iter().enumerate() {
min_max_decimate_into(&samples[k * n..(k + 1) * n], &mut self.env[r * width..(r + 1) * width]);
}
}
}
}
fn draw_traces(&mut self, canvas: &mut Canvas, events: &SpikeEventStore, req: &RenderRequest, has_samples: bool, scale: f32) {
let num_channels = req.channels.len();
if num_channels == 0 {
return;
}
let lane_h = canvas.height as f32 / num_channels as f32;
let k = px_per_unit(lane_h, req.amplitude_scale, scale);
let thickness = req.scale.max(1.0);
let t_end = req.window_start_sec + req.window_sec;
let w = canvas.width;
for lane in 0..num_channels {
let ch = req.channels[lane];
if !self.valid[lane] {
continue;
}
let color = req.palette.channel(ch);
let center = (lane as f32 + 0.5) * lane_h;
let by = center.round() as usize;
for x in (0..canvas.width).filter(|x| x % 4 != 0) {
canvas.set(x, by, req.palette.baseline);
}
for (color, spikes) in &req.highlights {
for &(t, sch) in spikes {
if sch != ch || t < req.window_start_sec || t > t_end {
continue;
}
let x = ((t - req.window_start_sec) / req.window_sec * canvas.width as f64) as f32;
let top = lane as f32 * lane_h;
canvas.rect(x - 2.0 * req.scale, top, x + 2.0 * req.scale, top + lane_h, *color, 0.16);
canvas.rect(x - thickness, top + 1.0, x + thickness, top + 1.0 + 10.0 * req.scale, *color, 1.0);
}
}
for &t in events.in_window(ch, req.window_start_sec, t_end) {
let x = ((t - req.window_start_sec) / req.window_sec * canvas.width as f64) as usize;
let top = (lane as f32 * lane_h) as usize;
let bottom = ((lane + 1) as f32 * lane_h) as usize;
canvas.vline_alpha(x, top, bottom, req.palette.marker, 0.18);
let tick = (6.0 * req.scale) as usize;
for dx in 0..(thickness as usize).max(1) {
canvas.vline(x + dx, top + 1, top + 1 + tick, req.palette.marker);
}
}
if !has_samples {
continue;
}
let row = &self.env[lane * w..(lane + 1) * w];
let clip = (0.0, canvas.height as f32 - 1.0);
let dim = req.overlay.as_ref().is_some_and(|o| o.dim);
let color = match req.color_by {
ColorBy::Single => req.palette.text,
_ => color,
};
for x in 0..w {
if let Some((a, b)) = column_span(row, x, center, k, clip) {
if dim {
canvas.span_aa_alpha(x, a - thickness * 0.5, b + thickness * 0.5, req.palette.text, DIM_ALPHA);
} else if req.color_by == ColorBy::Amplitude {
let [mn, mx] = row[x];
let v = (mn.abs().max(mx.abs()) * k / (lane_h * 0.5)).min(1.0);
canvas.span_aa(x, a - thickness * 0.5, b + thickness * 0.5, req.palette.heat(0.25 + 0.75 * v));
} else {
canvas.span_aa(x, a - thickness * 0.5, b + thickness * 0.5, color);
}
}
}
if let Some(o) = &req.overlay {
let px_per_sec = w as f64 / req.window_sec;
for m in &o.marks {
let Some(unit) = o.units.get(m.unit as usize) else { continue };
if unit.channels.binary_search(&ch).is_err() {
continue;
}
let x0 = ((m.start_sec - req.window_start_sec) * px_per_sec).floor().max(0.0) as usize;
let x1 = (((m.end_sec - req.window_start_sec) * px_per_sec).ceil().max(0.0) as usize).min(w.saturating_sub(1));
for x in x0..=x1 {
if let Some((a, b)) = column_span(row, x, center, k, clip) {
canvas.span_aa(x, a - thickness * 0.5, b + thickness * 0.5, unit.color);
}
}
}
}
}
let bar = if req.scale_bar { scale_bar_value(lane_h, k) } else { 0.0 };
if bar > 0.0 {
let bar_h = bar * k;
let x = canvas.width.saturating_sub((12.0 * req.scale) as usize);
let bottom = canvas.height as f32 - 6.0 * req.scale;
let top = (bottom - bar_h).max(0.0);
for dx in 0..(thickness as usize).max(1) {
canvas.vline(x + dx, top as usize, bottom as usize, req.palette.text);
}
}
}
fn draw_heatmap(&mut self, canvas: &mut Canvas, req: &RenderRequest, has_samples: bool, scale: f32) {
let channels = req.channels.len();
let (w, h) = (canvas.width, canvas.height);
if !has_samples || channels == 0 {
return;
}
self.heat.resize(channels * w, 0.0);
let norm = req.amplitude_scale / (scale * 1.5);
for r in 0..channels {
let row = &mut self.heat[r * w..(r + 1) * w];
if !self.valid[r] {
row.fill(0.0);
continue;
}
for (v, &[mn, mx]) in row.iter_mut().zip(&self.env[r * w..(r + 1) * w]) {
let a = if mn.is_finite() { (mn.abs().max(mx.abs()) * norm).min(1.0) } else { 0.0 };
*v = a * a;
}
}
for y in 0..h {
let c0 = y * channels / h;
let c1 = ((y + 1) * channels / h).max(c0 + 1).min(channels);
for x in 0..w {
let mut v = 0.0f32;
for ch in c0..c1 {
v = v.max(self.heat[ch * w + x]);
}
canvas.set(x, y, req.palette.heat(v));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::recording::Dataset;
fn request(ds: Dataset, mode: TimeViewKind) -> RenderRequest {
let events = Arc::new(SpikeEventStore::detect(ds.signal().as_ref()));
let channels = match mode {
TimeViewKind::Traces => (0..4.min(ds.total_channels)).collect(),
TimeViewKind::Heatmap | TimeViewKind::Raster => (0..ds.total_channels).collect(),
};
RenderRequest {
signal: ds.signal().clone(),
events,
width: 800,
height: 400,
scale: 1.0,
mode,
channels,
window_start_sec: 0.0,
window_sec: 0.100,
start_time_sec: 0.0,
amplitude_scale: 1.0,
nominal: nominal_amplitude(&SignalUnit::Microvolt),
auto_scale: false,
remove_dc: false,
scale_hint: 0.0,
grid_times: vec![0.05],
scale_bar: true,
highlights: vec![(crate::ui::plot::canvas::Pixel::rgb(255, 0, 0), vec![(0.05, 0)])],
overlay: None,
raster: None,
color_by: ColorBy::default(),
palette: Palette::DARK,
}
}
#[test]
fn test_renderer_output_dimensions_and_trace_pixels() {
let mut r = WaveformRenderer::default();
for mode in [TimeViewKind::Traces, TimeViewKind::Heatmap] {
let req = request(Dataset::generate_synthetic(8, 30_000.0, 0.5), mode);
let buf = r.render(&req);
assert_eq!((buf.width, buf.height), (800, 400));
assert!(buf.pixels().iter().any(|p| *p != Palette::DARK.background));
}
}
#[test]
fn test_out_of_range_channels_are_skipped() {
let mut req = request(Dataset::generate_synthetic(2, 10_000.0, 0.2), TimeViewKind::Traces);
req.channels = vec![0, 99];
let mut r = WaveformRenderer::default();
assert_eq!(r.render(&req).width, 800);
req.mode = TimeViewKind::Heatmap;
assert_eq!(r.render(&req).width, 800);
}
fn offset_square() -> Dataset {
let data: Vec<f32> = (0..10_000).map(|s| 1000.0 + if (s / 50) % 2 == 0 { 100.0 } else { -100.0 }).collect();
Dataset::from_samples("sq", data, 1, 10_000.0)
}
#[test]
fn test_auto_scale_dc_and_hysteresis() {
let mut r = WaveformRenderer::default();
let base = RenderRequest { channels: vec![0], window_sec: 0.5, auto_scale: true, ..request(offset_square(), TimeViewKind::Traces) };
let (_, with_dc) = r.render_scaled(&base);
assert!((with_dc - 1100.0).abs() < 1.0, "{with_dc}");
let (_, no_dc) = r.render_scaled(&RenderRequest { remove_dc: true, ..base.clone() });
assert!((no_dc - 100.0).abs() < 1.0, "{no_dc}");
let (_, kept) = r.render_scaled(&RenderRequest { remove_dc: true, scale_hint: 110.0, ..base.clone() });
assert_eq!(kept, 110.0);
let (_, replaced) = r.render_scaled(&RenderRequest { remove_dc: true, scale_hint: 1000.0, ..base.clone() });
assert!((replaced - 100.0).abs() < 1.0);
let (_, fixed) = r.render_scaled(&RenderRequest { auto_scale: false, ..base });
assert_eq!(fixed, nominal_amplitude(&SignalUnit::Microvolt));
}
#[test]
fn test_source_starting_inside_the_window_draws_only_its_part() {
let mut ds = Dataset::generate_synthetic(1, 1000.0, 1.0);
ds.start_time_sec = 1.0;
let req = RenderRequest {
channels: vec![0],
window_start_sec: 0.0,
window_sec: 2.0,
start_time_sec: 1.0,
grid_times: Vec::new(),
events: Arc::new(SpikeEventStore::default()),
highlights: Vec::new(),
overlay: None,
raster: None,
color_by: ColorBy::default(),
..request(ds, TimeViewKind::Traces)
};
let buf = WaveformRenderer::default().render(&req);
let (w, h) = (buf.width as usize, buf.height as usize);
let px = buf.pixels();
let drawn = |x0: usize, x1: usize| (0..h).any(|y| (x0..x1).any(|x| px[y * w + x] != Palette::DARK.background && px[y * w + x] != Palette::DARK.baseline));
assert!(!drawn(0, w / 2 - 2), "no data before the source starts");
assert!(drawn(w / 2 + 2, w - 20), "data after it starts");
}
struct Chunked {
inner: dspk_core::MemoryRecording,
chunk: u64,
reads: Arc<std::sync::Mutex<Vec<std::ops::Range<u64>>>>,
}
impl dspk_core::RecordingSource for Chunked {
fn info(&self) -> &dspk_core::RecordingInfo {
self.inner.info()
}
fn chunk_samples(&self) -> Option<u64> {
Some(self.chunk)
}
fn read(&self, channels: &[usize], samples: std::ops::Range<u64>, out: &mut [f32]) -> dspk_core::DspResult<()> {
self.reads.lock().unwrap().push(samples.clone());
self.inner.read(channels, samples, out)
}
}
#[test]
fn test_raw_windows_are_exact_and_read_each_chunk_once() {
let (nch, total, width) = (3usize, 10_000usize, 37usize);
let data: Vec<f32> = (0..nch * total).map(|i| ((i * 7919) % 1013) as f32 - 500.0).collect();
let reads = Arc::new(std::sync::Mutex::new(Vec::new()));
let inner = dspk_core::MemoryRecording::new("chunked", data.clone(), nch, 1000.0).unwrap();
let src = Chunked { inner, chunk: 300, reads: reads.clone() };
let ds = Dataset::local(Arc::new(src), None).unwrap();
let (start, end) = (1_234usize, 9_876usize);
assert!(((end - start) / width) < dspk_view::MEMORY_BASE as usize, "a raw window");
let mut r = WaveformRenderer::default();
let rows = [2usize, 0];
r.envelope(ds.signal().as_ref(), &rows, start, end, width);
let n = end - start;
for (k, &ch) in rows.iter().enumerate() {
for x in 0..width {
let (c0, c1) = (start + x * n / width, start + (x + 1) * n / width);
let col = &data[ch * total + c0..ch * total + c1];
let exact = col.iter().fold([f32::INFINITY, f32::NEG_INFINITY], |[a, b], &v| [a.min(v), b.max(v)]);
assert_eq!(r.env[k * width + x], exact, "row {k} column {x}");
}
}
let reads = reads.lock().unwrap();
assert_eq!(reads.first().unwrap().start, start as u64);
assert_eq!(reads.last().unwrap().end, end as u64);
for w in reads.windows(2) {
assert_eq!(w[0].end, w[1].start, "contiguous, no sample read twice");
assert_eq!(w[0].end % 300, 0, "blocks end on chunk boundaries");
}
}
#[test]
#[ignore]
fn bench_render() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../playground/data");
let cases = [
("mearec_32ch_10s.bin", TimeViewKind::Traces, 0.1),
("mearec_32ch_10s.bin", TimeViewKind::Traces, 1.0),
("mearec_32ch_10s.bin", TimeViewKind::Heatmap, 1.0),
("mock_signal_384ch.bin", TimeViewKind::Traces, 1.0),
("mock_signal_384ch.bin", TimeViewKind::Heatmap, 1.0),
];
for (file, mode, window_sec) in cases {
let Ok(sources) = crate::data::recording::SourceSet::open(&root.join(file)) else {
println!("skip {file} (not found)");
continue;
};
let ds = Dataset::new(sources.default_dataset().signal().clone(), None);
let mut r = WaveformRenderer::default();
let mut req = RenderRequest { width: 1136, height: 550, window_sec, ..request(ds, mode) };
if mode == TimeViewKind::Traces {
req.channels = (0..8).collect();
}
r.render(&req); let n = 20;
let t0 = std::time::Instant::now();
for _ in 0..n {
std::hint::black_box(r.render(&req));
}
let ms = t0.elapsed().as_secs_f64() * 1000.0 / n as f64;
println!("{file:24} {mode:?} {window_sec:>4}s {ms:6.2} ms/frame");
}
}
}