#[cfg(feature = "gpu")]
use crate::render::gpu::GpuRenderer;
use crate::{
core::plot::{Image, InteractiveViewportSnapshot},
core::{
FramePacing, HitResult, InteractivePlotSession, Plot, PlotInputEvent, QualityPolicy,
ReactiveSubscription, Result, SurfaceTarget, ViewportPoint,
},
interactive::{
event::{Annotation, Point2D, Rectangle},
state::{DataPoint, DataPointId, InteractionState},
},
render::{Color, FontConfig, FontFamily, TextRenderer, skia::SkiaRenderer},
};
use std::{
collections::HashMap,
sync::Arc,
time::{Duration, Instant},
};
#[derive(Clone, Debug)]
pub(crate) enum InteractiveRenderOutput {
Pixels(Vec<u8>),
Layers(InteractiveLayerOutput),
}
#[derive(Clone, Debug)]
pub(crate) struct InteractiveLayerOutput {
pub base: Arc<Image>,
pub overlays: Vec<Arc<Image>>,
}
pub struct RealTimeRenderer {
#[cfg(feature = "gpu")]
gpu_renderer: Option<GpuRenderer>,
cpu_renderer: SkiaRenderer,
current_plot: Option<Plot>,
interactive_session: Option<InteractivePlotSession>,
render_cache: RenderCache,
performance_monitor: PerformanceMonitor,
last_device_scale: f32,
hover_highlight_color: Color,
selection_highlight_color: Color,
brush_color: Color,
brush_outline_color: Color,
annotation_renderer: AnnotationRenderer,
quality_mode: RenderQuality,
adaptive_quality: bool,
target_fps: f64,
}
impl RealTimeRenderer {
pub async fn new() -> Result<Self> {
#[cfg(feature = "gpu")]
let gpu_renderer = match crate::render::gpu::initialize_gpu_backend().await {
Ok(_) => match GpuRenderer::new().await {
Ok(renderer) => {
log::info!("Interactive GPU renderer initialized");
Some(renderer)
}
Err(e) => {
log::warn!("GPU not available for interactive mode: {}", e);
None
}
},
Err(e) => {
log::warn!("GPU backend initialization failed: {}", e);
None
}
};
let cpu_renderer = SkiaRenderer::new(800, 600, crate::render::Theme::default())?;
Ok(Self {
#[cfg(feature = "gpu")]
gpu_renderer,
cpu_renderer,
current_plot: None,
interactive_session: None,
render_cache: RenderCache::new(),
performance_monitor: PerformanceMonitor::new(),
last_device_scale: 1.0,
hover_highlight_color: Color::new_rgba(255, 165, 0, 180), selection_highlight_color: Color::new_rgba(255, 0, 0, 120), brush_color: Color::new_rgba(0, 100, 255, 60), brush_outline_color: Color::new_rgba(96, 208, 255, 220),
annotation_renderer: AnnotationRenderer::new(),
quality_mode: RenderQuality::Interactive,
adaptive_quality: true,
target_fps: 60.0,
})
}
pub fn set_plot(&mut self, plot: Plot) {
self.interactive_session = Some(plot.prepare_interactive());
self.current_plot = Some(plot);
self.render_cache.invalidate_all();
}
pub fn set_device_scale(&mut self, device_scale: f32) {
self.last_device_scale = Self::sanitize_device_scale(device_scale);
}
pub(crate) fn apply_session_input(
&mut self,
event: PlotInputEvent,
size_px: (u32, u32),
device_scale: f32,
) -> bool {
self.set_device_scale(device_scale);
if let Some(session) = &self.interactive_session {
self.sync_session_target(session, size_px, self.last_device_scale);
let dirty_before = session.dirty_domains();
session.apply_input(event);
return session.dirty_domains() != dirty_before;
}
false
}
pub(crate) fn subscribe_reactive<F>(&self, callback: F) -> Option<ReactiveSubscription>
where
F: Fn() + Send + Sync + 'static,
{
self.interactive_session
.as_ref()
.map(|session| session.subscribe_reactive(callback))
}
pub(crate) fn viewport_snapshot(&self) -> Result<Option<InteractiveViewportSnapshot>> {
let Some(session) = &self.interactive_session else {
return Ok(None);
};
session.viewport_snapshot().map(Some)
}
pub(crate) fn restore_visible_bounds(
&mut self,
visible_bounds: crate::core::ViewportRect,
size_px: (u32, u32),
device_scale: f32,
) -> bool {
self.set_device_scale(device_scale);
if let Some(session) = &self.interactive_session {
self.sync_session_target(session, size_px, self.last_device_scale);
return session.restore_visible_bounds(visible_bounds);
}
false
}
pub(crate) fn render_interactive(
&mut self,
state: &InteractionState,
width: u32,
height: u32,
device_scale: f32,
) -> Result<InteractiveRenderOutput> {
let frame_start = Instant::now();
self.update_dimensions(width, height)?;
self.set_device_scale(device_scale);
if self.adaptive_quality {
self.update_quality_mode(state);
}
if self.interactive_session.is_some() {
let frame = self.render_session_frame(state, width, height, device_scale)?;
self.performance_monitor.record_frame(frame_start.elapsed());
return Ok(frame);
}
let mut pixel_data = self.render_base_plot(state, width, height, device_scale)?;
self.render_hover_highlight(state, &mut pixel_data)?;
self.render_selection_highlight(state, &mut pixel_data)?;
self.render_brush_region(state, &mut pixel_data)?;
self.render_annotations(state, &mut pixel_data)?;
self.render_tooltip(state, &mut pixel_data)?;
self.performance_monitor.record_frame(frame_start.elapsed());
Ok(InteractiveRenderOutput::Pixels(pixel_data))
}
pub fn render_publication(
&mut self,
plot: &Plot,
width: u32,
height: u32,
dpi: f32,
) -> Result<Vec<u8>> {
let old_quality = self.quality_mode;
self.quality_mode = RenderQuality::Publication;
self.cpu_renderer = SkiaRenderer::new(width, height, crate::render::Theme::default())?;
let plot_clone = plot
.clone()
.dpi(dpi as u32)
.set_output_pixels(width, height);
let result = match plot_clone.render() {
Ok(image) => image.pixels,
Err(e) => {
log::warn!("Publication render failed: {}, returning white pixels", e);
vec![255u8; (width * height * 4) as usize]
}
};
self.quality_mode = old_quality;
Ok(result)
}
pub fn get_data_point_at(
&self,
screen_pos: Point2D,
_state: &InteractionState,
) -> Option<DataPoint> {
if let Some(session) = &self.interactive_session {
self.sync_session_target(
session,
(self.cpu_renderer.width(), self.cpu_renderer.height()),
self.last_device_scale,
);
match session.hit_test(ViewportPoint::new(screen_pos.x, screen_pos.y)) {
HitResult::SeriesPoint {
series_index,
point_index,
data_position,
..
} => {
return Some(DataPoint::new(
point_index,
data_position.x,
data_position.y,
data_position.y,
series_index,
));
}
HitResult::HeatmapCell {
series_index,
row,
col,
value,
..
} => {
return Some(
DataPoint::new(
row.saturating_mul(10_000) + col,
col as f64,
row as f64,
value,
series_index,
)
.with_metadata("kind".to_string(), "heatmap".to_string()),
);
}
HitResult::None => {}
}
}
None
}
pub fn get_points_in_region(
&self,
region: Rectangle,
state: &InteractionState,
) -> Vec<DataPointId> {
let mut points = Vec::new();
let data_min = state.screen_to_data(region.min);
let data_max = state.screen_to_data(region.max);
let data_region = Rectangle::from_points(data_min, data_max);
for i in 0..100 {
let test_point = Point2D::new(i as f64 % 100.0, (i as f64 * 0.5) % 100.0);
if data_region.contains(test_point) {
points.push(DataPointId(i));
}
}
points
}
fn update_dimensions(&mut self, width: u32, height: u32) -> Result<()> {
if self.cpu_renderer.width() != width || self.cpu_renderer.height() != height {
self.cpu_renderer = SkiaRenderer::new(width, height, crate::render::Theme::default())?;
self.render_cache.invalidate_all();
}
Ok(())
}
fn update_quality_mode(&mut self, state: &InteractionState) {
let active_interaction = state.mouse_button_pressed
|| state.brush_active
|| state.viewport_dirty
|| !matches!(
state.animation_state,
crate::interactive::state::AnimationState::Idle
);
let current_fps = self.performance_monitor.get_current_fps();
let is_animating = !matches!(
state.animation_state,
crate::interactive::state::AnimationState::Idle
);
if active_interaction || is_animating || current_fps < self.target_fps * 0.8 {
self.quality_mode = RenderQuality::Interactive;
} else if current_fps > self.target_fps * 0.95 {
self.quality_mode = RenderQuality::Balanced;
}
}
fn render_session_frame(
&mut self,
state: &InteractionState,
width: u32,
height: u32,
device_scale: f32,
) -> Result<InteractiveRenderOutput> {
let session = self
.interactive_session
.as_ref()
.expect("interactive session should exist for session rendering");
self.sync_session_target(session, (width, height), device_scale);
session.set_frame_pacing(FramePacing::Display);
session.set_quality_policy(match self.quality_mode {
RenderQuality::Interactive => QualityPolicy::Interactive,
RenderQuality::Balanced => QualityPolicy::Balanced,
RenderQuality::Publication => QualityPolicy::Publication,
});
#[cfg(feature = "gpu")]
session.set_prefer_gpu(self.gpu_renderer.is_some());
let frame = match session.render_to_surface(SurfaceTarget {
size_px: (width, height),
scale_factor: device_scale,
time_seconds: 0.0,
}) {
Ok(frame) => frame,
Err(e) => {
log::warn!(
"Interactive session surface rendering failed: {}, returning white pixels",
e
);
let num_bytes = (width as usize)
.saturating_mul(height as usize)
.saturating_mul(4);
return Ok(InteractiveRenderOutput::Pixels(vec![255u8; num_bytes]));
}
};
let mut overlays = Vec::new();
if let Some(overlay) = frame.layers.overlay.as_ref() {
overlays.push(Arc::clone(overlay));
}
if let Some(local_overlay) = self.render_local_overlay(state, width, height, true)? {
overlays.push(Arc::new(Image::new(width, height, local_overlay)));
}
Ok(InteractiveRenderOutput::Layers(InteractiveLayerOutput {
base: Arc::clone(&frame.layers.base),
overlays,
}))
}
fn render_base_plot(
&mut self,
state: &InteractionState,
width: u32,
height: u32,
device_scale: f32,
) -> Result<Vec<u8>> {
if !state.needs_redraw && !state.viewport_dirty {
if let Some(cached) = self
.render_cache
.get_base_render(state.zoom_level, state.pan_offset)
{
return Ok(cached);
}
}
let has_plot = self.current_plot.is_some();
let pixel_data = if has_plot {
match self.quality_mode {
RenderQuality::Interactive => {
self.render_interactive_quality(state, width, height, device_scale)?
}
RenderQuality::Balanced => {
self.render_balanced_quality(state, width, height, device_scale)?
}
RenderQuality::Publication => {
self.render_plot_to_pixels(state, width, height, device_scale)?
}
}
} else {
vec![255u8; (width * height * 4) as usize] };
self.render_cache
.store_base_render(state.zoom_level, state.pan_offset, pixel_data.clone());
Ok(pixel_data)
}
fn render_interactive_quality(
&mut self,
state: &InteractionState,
width: u32,
height: u32,
device_scale: f32,
) -> Result<Vec<u8>> {
self.render_plot_to_pixels(state, width, height, device_scale)
}
fn render_balanced_quality(
&mut self,
state: &InteractionState,
width: u32,
height: u32,
device_scale: f32,
) -> Result<Vec<u8>> {
self.render_plot_to_pixels(state, width, height, device_scale)
}
fn render_plot_to_pixels(
&self,
_state: &InteractionState,
width: u32,
height: u32,
device_scale: f32,
) -> Result<Vec<u8>> {
if let Some(ref plot) = self.current_plot {
let plot_clone = Self::configure_plot_for_surface(plot, width, height, device_scale);
match plot_clone.render() {
Ok(image) => Ok(image.pixels),
Err(e) => {
log::warn!("Plot rendering failed: {}, returning white pixels", e);
Ok(vec![255u8; (width * height * 4) as usize])
}
}
} else {
Ok(vec![255u8; (width * height * 4) as usize])
}
}
fn render_local_overlay(
&mut self,
state: &InteractionState,
width: u32,
height: u32,
session_managed_overlay: bool,
) -> Result<Option<Vec<u8>>> {
let render_annotations = !state.annotations.is_empty()
&& !(session_managed_overlay && self.should_defer_nonessential_overlays(state));
let render_brush = state.brushed_region.is_some();
if !render_annotations && !render_brush {
return Ok(None);
}
let mut pixel_data = vec![0u8; (width * height * 4) as usize];
if render_brush {
self.render_brush_region(state, &mut pixel_data)?;
}
if render_annotations {
self.render_annotations(state, &mut pixel_data)?;
}
Ok(Some(pixel_data))
}
fn should_defer_nonessential_overlays(&self, state: &InteractionState) -> bool {
matches!(self.quality_mode, RenderQuality::Interactive)
&& (state.mouse_button_pressed
|| state.brush_active
|| !matches!(
state.animation_state,
crate::interactive::state::AnimationState::Idle
))
}
fn configure_plot_for_surface(plot: &Plot, width: u32, height: u32, device_scale: f32) -> Plot {
let min_device_scale = crate::core::constants::dpi::MIN as f32 / crate::core::REFERENCE_DPI;
let device_scale = if !device_scale.is_finite() || device_scale <= 0.0 {
1.0
} else {
device_scale.max(min_device_scale)
};
let interactive_dpi = (crate::core::REFERENCE_DPI * device_scale).round() as u32;
plot.clone()
.dpi(interactive_dpi)
.set_output_pixels(width, height)
}
fn sync_session_target(
&self,
session: &InteractivePlotSession,
size_px: (u32, u32),
device_scale: f32,
) {
session.resize(size_px, Self::sanitize_device_scale(device_scale));
}
fn render_hover_highlight(
&mut self,
state: &InteractionState,
pixel_data: &mut [u8],
) -> Result<()> {
if self.interactive_session.is_some() {
return Ok(());
}
if let Some(ref hover_point) = state.hover_point {
let screen_pos = state.data_to_screen(hover_point.position);
self.draw_highlight_circle(pixel_data, screen_pos, 8.0, self.hover_highlight_color)?;
}
Ok(())
}
fn render_selection_highlight(
&mut self,
state: &InteractionState,
pixel_data: &mut [u8],
) -> Result<()> {
if self.interactive_session.is_some() {
return Ok(());
}
for point_id in &state.selected_points {
let screen_pos = Point2D::new(100.0 + point_id.0 as f64 * 50.0, 100.0);
self.draw_highlight_circle(
pixel_data,
screen_pos,
6.0,
self.selection_highlight_color,
)?;
}
Ok(())
}
fn render_brush_region(
&mut self,
state: &InteractionState,
pixel_data: &mut [u8],
) -> Result<()> {
if let Some(region) = state.brushed_region {
self.draw_brush_guide_rectangle(pixel_data, region)?;
}
Ok(())
}
fn render_annotations(
&mut self,
state: &InteractionState,
pixel_data: &mut [u8],
) -> Result<()> {
if state.annotations.is_empty() {
return Ok(());
}
for annotation in &state.annotations {
self.annotation_renderer.render_annotation(
annotation,
state,
pixel_data,
self.cpu_renderer.width(),
self.cpu_renderer.height(),
)?;
}
Ok(())
}
fn render_tooltip(&mut self, state: &InteractionState, pixel_data: &mut [u8]) -> Result<()> {
if self.interactive_session.is_some() {
return Ok(());
}
if state.tooltip_visible && !state.tooltip_content.is_empty() {
self.draw_tooltip(pixel_data, &state.tooltip_content, state.tooltip_position)?;
}
Ok(())
}
fn draw_highlight_circle(
&self,
pixel_data: &mut [u8],
center: Point2D,
radius: f32,
color: Color,
) -> Result<()> {
let width = self.cpu_renderer.width() as i32;
let height = self.cpu_renderer.height() as i32;
let r_sq = (radius * radius) as i32;
let cx = center.x as i32;
let cy = center.y as i32;
for dy in -(radius as i32)..=(radius as i32) {
for dx in -(radius as i32)..=(radius as i32) {
if dx * dx + dy * dy <= r_sq {
let x = cx + dx;
let y = cy + dy;
if x >= 0 && x < width && y >= 0 && y < height {
let index = ((y * width + x) * 4) as usize;
if index + 3 < pixel_data.len() {
let alpha = color.a as f32 / 255.0;
pixel_data[index] = blend_channel(pixel_data[index], color.r, alpha);
pixel_data[index + 1] =
blend_channel(pixel_data[index + 1], color.g, alpha);
pixel_data[index + 2] =
blend_channel(pixel_data[index + 2], color.b, alpha);
}
}
}
}
}
Ok(())
}
fn draw_selection_rectangle(
&self,
pixel_data: &mut [u8],
region: Rectangle,
color: Color,
) -> Result<()> {
let width = self.cpu_renderer.width() as i32;
let height = self.cpu_renderer.height() as i32;
let x1 = region.min.x as i32;
let y1 = region.min.y as i32;
let x2 = region.max.x as i32;
let y2 = region.max.y as i32;
let alpha = color.a as f32 / 255.0;
for y in y1.max(0)..=y2.min(height - 1) {
for x in x1.max(0)..=x2.min(width - 1) {
let index = ((y * width + x) * 4) as usize;
if index + 3 < pixel_data.len() {
pixel_data[index] = blend_channel(pixel_data[index], color.r, alpha);
pixel_data[index + 1] = blend_channel(pixel_data[index + 1], color.g, alpha);
pixel_data[index + 2] = blend_channel(pixel_data[index + 2], color.b, alpha);
}
}
}
Ok(())
}
fn draw_brush_guide_rectangle(&self, pixel_data: &mut [u8], region: Rectangle) -> Result<()> {
self.draw_selection_rectangle(pixel_data, region, self.brush_color)?;
self.draw_rectangle_outline(pixel_data, region, self.brush_outline_color, 2)
}
fn draw_rectangle_outline(
&self,
pixel_data: &mut [u8],
region: Rectangle,
color: Color,
thickness: i32,
) -> Result<()> {
let width = self.cpu_renderer.width() as i32;
let height = self.cpu_renderer.height() as i32;
let x1 = region.min.x.round() as i32;
let y1 = region.min.y.round() as i32;
let x2 = region.max.x.round() as i32;
let y2 = region.max.y.round() as i32;
let thickness = thickness.max(1);
let alpha = color.a as f32 / 255.0;
for y in y1.max(0)..=y2.min(height - 1) {
for x in x1.max(0)..=x2.min(width - 1) {
let on_border = x - x1 < thickness
|| x2 - x < thickness
|| y - y1 < thickness
|| y2 - y < thickness;
if !on_border {
continue;
}
let index = ((y * width + x) * 4) as usize;
if index + 3 < pixel_data.len() {
pixel_data[index] = blend_channel(pixel_data[index], color.r, alpha);
pixel_data[index + 1] = blend_channel(pixel_data[index + 1], color.g, alpha);
pixel_data[index + 2] = blend_channel(pixel_data[index + 2], color.b, alpha);
pixel_data[index + 3] = color.a;
}
}
}
Ok(())
}
fn draw_tooltip(&self, pixel_data: &mut [u8], content: &str, position: Point2D) -> Result<()> {
const TOOLTIP_FONT_SIZE: f32 = 13.0;
const TOOLTIP_PADDING_X: f64 = 8.0;
const TOOLTIP_PADDING_Y: f64 = 6.0;
const TOOLTIP_CURSOR_GAP: f64 = 12.0;
let text_renderer = TextRenderer::new();
let font = FontConfig::new(FontFamily::SansSerif, TOOLTIP_FONT_SIZE);
let (text_width, text_height) =
text_renderer
.measure_text(content, &font)
.unwrap_or_else(|_| {
(
content.chars().count() as f32 * TOOLTIP_FONT_SIZE * 0.6,
TOOLTIP_FONT_SIZE * 1.2,
)
});
let tooltip_width = f64::from(text_width) + TOOLTIP_PADDING_X * 2.0;
let tooltip_height = f64::from(text_height) + TOOLTIP_PADDING_Y * 2.0;
let view_width = self.cpu_renderer.width() as f64;
let view_height = self.cpu_renderer.height() as f64;
let max_left = (view_width - tooltip_width).max(0.0);
let max_top = (view_height - tooltip_height).max(0.0);
let mut left = position.x + TOOLTIP_CURSOR_GAP;
if left + tooltip_width > view_width {
left = position.x - tooltip_width - TOOLTIP_CURSOR_GAP;
}
let mut top = position.y - tooltip_height - TOOLTIP_CURSOR_GAP;
if top < 0.0 {
top = position.y + TOOLTIP_CURSOR_GAP;
}
left = left.clamp(0.0, max_left);
top = top.clamp(0.0, max_top);
let tooltip_rect = Rectangle::new(left, top, left + tooltip_width, top + tooltip_height);
let tooltip_color = Color::new_rgba(255, 255, 220, 200); self.draw_selection_rectangle(pixel_data, tooltip_rect, tooltip_color)?;
let Some(size) =
tiny_skia::IntSize::from_wh(self.cpu_renderer.width(), self.cpu_renderer.height())
else {
log::debug!("Skipping legacy tooltip text render because frame size is invalid");
return Ok(());
};
let Some(mut pixmap) =
tiny_skia::PixmapMut::from_bytes(pixel_data, size.width(), size.height())
else {
log::debug!("Skipping legacy tooltip text render because pixmap creation failed");
return Ok(());
};
if let Err(err) = text_renderer.render_text_mut(
&mut pixmap,
content,
(left + TOOLTIP_PADDING_X) as f32,
(top + TOOLTIP_PADDING_Y) as f32,
&font,
Color::new_rgba(24, 24, 24, 255),
) {
log::debug!(
"Skipping legacy tooltip text render after text rasterization failed: {err}"
);
return Ok(());
}
Ok(())
}
fn sanitize_device_scale(device_scale: f32) -> f32 {
if device_scale.is_finite() && device_scale > 0.0 {
device_scale
} else {
1.0
}
}
pub fn get_performance_stats(&self) -> PerformanceStats {
self.performance_monitor.get_stats()
}
}
fn blend_channel(background: u8, foreground: u8, alpha: f32) -> u8 {
let bg = background as f32 / 255.0;
let fg = foreground as f32 / 255.0;
let result = bg * (1.0 - alpha) + fg * alpha;
(result * 255.0) as u8
}
struct RenderCache {
base_renders: HashMap<CacheKey, Vec<u8>>,
max_entries: usize,
}
#[derive(Hash, PartialEq, Eq, Clone)]
struct CacheKey {
zoom_level_bits: u64,
pan_x_bits: u64,
pan_y_bits: u64,
}
impl RenderCache {
fn new() -> Self {
Self {
base_renders: HashMap::new(),
max_entries: 10,
}
}
fn get_base_render(
&self,
zoom_level: f64,
pan_offset: crate::interactive::event::Vector2D,
) -> Option<Vec<u8>> {
let key = Self::make_key(zoom_level, pan_offset);
self.base_renders.get(&key).cloned()
}
fn store_base_render(
&mut self,
zoom_level: f64,
pan_offset: crate::interactive::event::Vector2D,
pixel_data: Vec<u8>,
) {
if self.base_renders.len() >= self.max_entries {
if let Some(first_key) = self.base_renders.keys().next().cloned() {
self.base_renders.remove(&first_key);
}
}
let key = Self::make_key(zoom_level, pan_offset);
self.base_renders.insert(key, pixel_data);
}
fn invalidate_all(&mut self) {
self.base_renders.clear();
}
fn make_key(zoom_level: f64, pan_offset: crate::interactive::event::Vector2D) -> CacheKey {
CacheKey {
zoom_level_bits: (zoom_level * 100.0) as u64, pan_x_bits: (pan_offset.x * 100.0) as u64,
pan_y_bits: (pan_offset.y * 100.0) as u64,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum RenderQuality {
Interactive, Balanced, Publication, }
struct PerformanceMonitor {
frame_times: Vec<Duration>,
frame_count: u64,
last_fps_calculation: Instant,
target_frame_time: Duration,
}
impl PerformanceMonitor {
fn new() -> Self {
Self {
frame_times: Vec::with_capacity(60),
frame_count: 0,
last_fps_calculation: Instant::now(),
target_frame_time: Duration::from_nanos(16_666_667), }
}
fn record_frame(&mut self, frame_time: Duration) {
self.frame_times.push(frame_time);
self.frame_count += 1;
if self.frame_times.len() > 60 {
self.frame_times.remove(0);
}
}
fn get_current_fps(&self) -> f64 {
if self.frame_times.is_empty() {
return 0.0;
}
let avg_frame_time: Duration =
self.frame_times.iter().sum::<Duration>() / self.frame_times.len() as u32;
1.0 / avg_frame_time.as_secs_f64()
}
fn get_stats(&self) -> PerformanceStats {
PerformanceStats {
current_fps: self.get_current_fps(),
frame_count: self.frame_count,
avg_frame_time: if !self.frame_times.is_empty() {
self.frame_times.iter().sum::<Duration>() / self.frame_times.len() as u32
} else {
Duration::ZERO
},
}
}
}
#[derive(Debug, Clone)]
pub struct PerformanceStats {
pub current_fps: f64,
pub frame_count: u64,
pub avg_frame_time: Duration,
}
struct AnnotationRenderer;
impl AnnotationRenderer {
fn new() -> Self {
Self
}
fn render_annotation(
&self,
annotation: &Annotation,
state: &InteractionState,
pixel_data: &mut [u8],
width: u32,
height: u32,
) -> Result<()> {
match annotation {
Annotation::Text {
content: _,
position,
style: _,
} => {
let _ = state.data_to_screen(*position);
}
Annotation::Arrow {
start,
end,
style: _,
} => {
let _ = state.data_to_screen(*start);
let _ = state.data_to_screen(*end);
}
Annotation::Shape { geometry, style: _ } => {
let _ = geometry;
}
Annotation::Equation {
latex: _,
position,
style: _,
} => {
let _ = state.data_to_screen(*position);
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::REFERENCE_DPI;
#[tokio::test]
async fn test_renderer_creation() {
let renderer_result = RealTimeRenderer::new().await;
assert!(renderer_result.is_ok());
}
#[test]
fn test_render_cache() {
let mut cache = RenderCache::new();
let zoom = 1.5;
let pan = crate::interactive::event::Vector2D::new(10.0, 20.0);
let test_data = vec![255u8; 100];
cache.store_base_render(zoom, pan, test_data.clone());
let retrieved = cache.get_base_render(zoom, pan);
assert_eq!(retrieved, Some(test_data));
cache.invalidate_all();
let retrieved_after_clear = cache.get_base_render(zoom, pan);
assert_eq!(retrieved_after_clear, None);
}
#[test]
fn test_performance_monitor() {
let mut monitor = PerformanceMonitor::new();
monitor.record_frame(Duration::from_millis(16)); monitor.record_frame(Duration::from_millis(17));
monitor.record_frame(Duration::from_millis(15));
let stats = monitor.get_stats();
assert!(stats.current_fps > 50.0 && stats.current_fps < 70.0);
assert_eq!(stats.frame_count, 3);
}
#[test]
fn test_alpha_blending() {
let background = 100u8;
let foreground = 200u8;
let alpha = 0.5;
let result = blend_channel(background, foreground, alpha);
let expected = (100.0 * 0.5 + 200.0 * 0.5) as u8;
assert_eq!(result, expected);
}
#[test]
fn test_configure_plot_for_surface_keeps_logical_size_on_hidpi() {
#[allow(deprecated)]
let plot = Plot::new()
.size_px(800, 600)
.line(&[0.0, 1.0], &[1.0, 2.0])
.end_series();
let configured = RealTimeRenderer::configure_plot_for_surface(&plot, 1600, 1200, 2.0);
let image = configured
.render()
.expect("configured HiDPI plot should render");
assert_eq!((image.width, image.height), (1600, 1200));
assert!((configured.get_config().figure.width - 8.0).abs() < f32::EPSILON);
assert!((configured.get_config().figure.height - 6.0).abs() < f32::EPSILON);
assert!((configured.get_config().figure.dpi - (REFERENCE_DPI * 2.0)).abs() < f32::EPSILON);
}
#[test]
fn test_configure_plot_for_surface_defaults_device_scale_to_one() {
#[allow(deprecated)]
let plot = Plot::new()
.size_px(800, 600)
.line(&[0.0, 1.0], &[1.0, 2.0])
.end_series();
let configured = RealTimeRenderer::configure_plot_for_surface(&plot, 800, 600, 0.0);
let image = configured
.render()
.expect("configured 1x plot should render");
assert_eq!((image.width, image.height), (800, 600));
assert!((configured.get_config().figure.width - 8.0).abs() < f32::EPSILON);
assert!((configured.get_config().figure.height - 6.0).abs() < f32::EPSILON);
assert!((configured.get_config().figure.dpi - REFERENCE_DPI).abs() < f32::EPSILON);
}
#[test]
fn test_configure_plot_for_surface_preserves_fractional_hidpi_framebuffer_size() {
#[allow(deprecated)]
let plot = Plot::new()
.size_px(800, 600)
.line(&[0.0, 1.0], &[1.0, 2.0])
.end_series();
let configured = RealTimeRenderer::configure_plot_for_surface(&plot, 1001, 751, 1.5);
let image = configured
.render()
.expect("configured fractional HiDPI plot should render");
assert_eq!((image.width, image.height), (1001, 751));
assert!((configured.get_config().figure.width - (1001.0 / 150.0)).abs() < 1e-6);
assert!((configured.get_config().figure.height - (751.0 / 150.0)).abs() < 1e-6);
assert!((configured.get_config().figure.dpi - 150.0).abs() < f32::EPSILON);
}
#[test]
fn test_configure_plot_for_surface_preserves_sub_1x_device_scale() {
#[allow(deprecated)]
let plot = Plot::new()
.size_px(800, 600)
.line(&[0.0, 1.0], &[1.0, 2.0])
.end_series();
let configured = RealTimeRenderer::configure_plot_for_surface(&plot, 800, 600, 0.75);
let image = configured
.render()
.expect("configured sub-1x plot should render");
assert_eq!((image.width, image.height), (800, 600));
assert!((configured.get_config().figure.width - (800.0 / 75.0)).abs() < 1e-6);
assert!((configured.get_config().figure.height - (600.0 / 75.0)).abs() < 1e-6);
assert!((configured.get_config().figure.dpi - 75.0).abs() < f32::EPSILON);
}
#[test]
fn test_set_device_scale_sanitizes_invalid_values() {
let runtime = tokio::runtime::Runtime::new().expect("runtime should initialize for tests");
let mut renderer = runtime
.block_on(RealTimeRenderer::new())
.expect("renderer should initialize for tests");
renderer.set_device_scale(2.0);
assert!((renderer.last_device_scale - 2.0).abs() < f32::EPSILON);
renderer.set_device_scale(0.0);
assert!((renderer.last_device_scale - 1.0).abs() < f32::EPSILON);
}
#[test]
fn test_brush_guide_rectangle_draws_outline_over_fill() {
let runtime = tokio::runtime::Runtime::new().expect("runtime should initialize for tests");
let mut renderer = runtime
.block_on(RealTimeRenderer::new())
.expect("renderer should initialize for tests");
let mut pixels = vec![
0u8;
(renderer.cpu_renderer.width() * renderer.cpu_renderer.height() * 4)
as usize
];
let mut state = InteractionState::default();
state.brushed_region = Some(Rectangle::new(24.0, 24.0, 88.0, 88.0));
renderer
.render_brush_region(&state, &mut pixels)
.expect("brush guide should render");
let width = renderer.cpu_renderer.width() as usize;
let border_index = ((24usize * width + 24usize) * 4) as usize;
let interior_index = ((56usize * width + 56usize) * 4) as usize;
assert!(
pixels[border_index + 3] > pixels[interior_index + 3],
"brush outline should be more visible than the fill interior"
);
}
}