#![feature(random)] use core::f32; use std::{ fs::{read_dir, OpenOptions}, io::Write, path::PathBuf, process::{Command, Stdio}, sync::mpsc::{self, Receiver, Sender}, thread::{self, JoinHandle}, time::{Duration, SystemTime}, }; use chrono::{Datelike, Local, Timelike}; use macroquad::prelude::*; const THICKNESS: f32 = 2.0; const TAIL_LEN: usize = 100; const BAUD_RATE: u32 = 57600; static mut PORT: Option = None; #[derive(Clone, Copy, PartialEq)] struct Point { x: f32, y: f32, time_since_last: f32, } impl Point { fn new(x: f32, y: f32, delta: f32) -> Self { Self { x, y, time_since_last: delta, } } } struct DebugWindow<'a> { text: Vec, text_margin: f32, font: Option<&'a Font>, } impl<'a> DebugWindow<'a> { fn new(font: Option<&'a Font>) -> Self { Self { text: Vec::new(), text_margin: 20.0, font, } } fn add_line(&mut self, line: String) { self.text.push(line); } fn get_params(&self) -> TextParams { let params = TextParams { color: WHITE, font: self.font, ..Default::default() }; params } fn draw(&self) { let params = self.get_params(); self.text.iter().enumerate().for_each(|(index, text)| { let x = self.text_margin; let y = self.text_margin + 20. * (index as f32 + 1.); // let measurement = measure_text(text, None, params.font_size, params.font_scale); // draw_rectangle(x,y,measurement.width,-measurement.height, Color { r: 1., g: 1., b: 1., a: 0.25 }); draw_text_ex(text, x, y, params.clone()); }); } } enum Axis { One, Two, } /// # Graph /// Holds 1 contiguous line. Can be graphed on 1 or 2 axis. struct Graph<'a> { font: Option<&'a Font>, points: [Point; TAIL_LEN], head: usize, axises: Axis, /// What the tail treats as 0 head_tracker: f32, /// The graph's local origin x_origin: f32, /// The graph's local origin y_origin: f32, /// How fast X scrolls when using a since axis tail px_per_s: f32, } impl<'a> Graph<'a> { fn recalculate_origin(&mut self) { let (x_offset, y_offset) = match self.axises { Axis::One => (0., screen_height() / 2.), Axis::Two => (screen_width() / 2., screen_height() / 2.), }; self.x_origin = x_offset; self.y_origin = y_offset; } fn new(axises: Axis, font: Option<&'a Font>) -> Self { let (x_offset, y_offset) = match axises { Axis::One => (0., screen_height() / 2.), Axis::Two => (screen_width() / 2., screen_height() / 2.), }; Self { font, // start all the dots off screen points: [Point::new(-1., -1., 0.); TAIL_LEN], head: 0, axises, x_origin: x_offset, y_origin: y_offset, px_per_s: 800., head_tracker: 0., } } fn draw_time(&self) { match self.axises { Axis::Two => {} Axis::One => { let ms_per_px = self.px_per_s / 1000.; // how many ms per segment let segment_len_ms = 100.; let px_offset = ms_per_px * segment_len_ms; let segments = screen_width() / px_offset; let mut params = TextParams::default(); params.font = self.font; params.font_size = 20; params.color = DARKGRAY; let y2 = screen_height(); for i in 0..=segments as i32 { let x = i as f32 * px_offset; draw_line(x, 0., x, y2, 2., DARKGRAY); draw_text_ex( &format!("{}ms", segment_len_ms * i as f32), x, y2 / 2., params.clone(), ); } } } } fn draw_axises(&self) { // Horzontal line // (0, y_origin) -> (max, y_origin) draw_line( 0., self.y_origin, screen_width(), self.y_origin, 1., DARKGRAY, ); // Vertical line // (x_origin, 0) -> (x_origin, max) draw_line( self.x_origin, 0., self.x_origin, screen_height(), 1., DARKGRAY, ); } fn place_next(&mut self, delta_time: f32, x: f32, y: f32) { match self.axises { Axis::One => { // x will scroll while y gets displaced if self.head_tracker >= screen_width() { self.head_tracker = 0.; } else { self.head_tracker = self.head_tracker + (delta_time * self.px_per_s) } self.push(Point::new( self.head_tracker, self.y_origin - y, delta_time, )); } Axis::Two => { // both x and y will get displaced self.push(Point::new( self.x_origin + x, self.y_origin - y, delta_time, )) } } } fn push(&mut self, dot: Point) { if self.head >= self.points.len() - 1 { self.head = 0 } else { self.head += 1 } self.points[self.head] = dot; } fn draw(&self) { self.points .into_iter() .enumerate() .for_each(|(index, point)| { // dots fade let virtual_index = if index > self.head { index - self.head } else { index + (self.points.len() - self.head) }; let faded = virtual_index as f32 / self.points.len() as f32; let faded_inverse = 1. - faded; let color = Color { r: faded_inverse, g: faded, b: 0.2, a: faded + 0.1, }; let prev = self.previous(index); if point.x == 0. || point.y == 0. || prev.x == -1. || prev.y == -1. { // Don't have lines that cross the whole screen while using 1D graph // draw_circle(point.x, point.y,THICKNESS, color); } else if self.head == index { // Draw the head differently draw_circle(point.x, point.y, THICKNESS * 2., color); draw_line(point.x, point.y, prev.x, prev.y, THICKNESS, color); } else if prev == self.points[self.head] { // Prevent the tail from connecting to the head draw_circle(point.x, point.y, THICKNESS, color); } else { draw_line(point.x, point.y, prev.x, prev.y, THICKNESS, color); } }); } fn previous(&self, index: usize) -> Point { let prev = if index > 0 { index - 1 } else { self.points.len() - 1 }; self.points[prev] } } async fn get_font() -> Option { #[cfg(target_os = "windows")] let fonts_path = "C:\\Windows\\Fonts"; #[cfg(target_os = "linux")] let fonts_path = "/usr/share/fonts/"; if let Ok(dir) = read_dir(fonts_path) { let selection = dir .filter_map(|f| f.ok()) .map(|f| (f.file_name(), f.path())) .map(|(a, b)| (a.into_string(), b)) .filter(|(a, _)| a.is_ok()) .map(|(a, b)| (a.unwrap(), b)) // Arial is a good windows default, Adwaita is a good Linux default .filter(|(a, _)| a.contains("Arial") | a.contains("Adwaita")) .map(|(_, b)| b) .collect::>(); if selection.len() > 0 { if let Ok(dir) = read_dir(&selection[0]) { let ttfs = dir .filter_map(|f| f.ok()) .map(|f| (f.file_name(), f.path())) .map(|(a, b)| (a.into_string(), b)) .filter(|(a, _)| a.is_ok()) .map(|(a, b)| (a.unwrap(), b)) .filter(|(a, _)| a.contains("Regular")) .filter(|(a, _)| a.contains("Mono")) .filter(|(a, _)| a.contains("ttf")) .map(|(_, b)| b) .collect::>(); if ttfs.len() > 0 { let selection = ttfs[0].to_str().expect("Bad path"); let ttf = load_ttf_font(selection) .await .expect("Cannot load ttf {selection}"); println!("Loaded \"{:?}\"", ttfs[0]); return Some(ttf); } else { eprintln!( "So sutable fonts found in the sub dir \"{:?}\"", selection[0] ); } } } else { eprintln!("No fonts found"); } } None } #[macroquad::main("Graph")] async fn main() { let font = get_font().await; // Dot params let mut graph = Graph::new(Axis::One, font.as_ref()); // Tooling let mut show_debug = true; let mut plot_delta_time = 0.; let mut pause = false; // Serial let (rx, _handle) = get_serial_port_or_demo(); loop { clear_background(BLACK); graph.recalculate_origin(); graph.draw_axises(); graph.draw_time(); let frame_delta_time = get_frame_time(); // keep track of the time since last plot plot_delta_time += frame_delta_time; match rx.try_recv() { Ok(x) => { if !pause { graph.place_next(plot_delta_time, 0., x as f32); plot_delta_time = 0.; } } Err(x) => match x { mpsc::TryRecvError::Empty => {} mpsc::TryRecvError::Disconnected => { eprintln!("Sender hung-up."); return; } }, } graph.draw(); if is_key_pressed(KeyCode::F1) { pause = !pause; } if is_key_pressed(KeyCode::F2) { // Dump data to csv if let Ok(mut file) = OpenOptions::new() .write(true) .append(false) .truncate(true) .create(true) .open("data.csv") { let mut x = 0.; for p in &graph.points { x += p.time_since_last; let _ = file.write_all(format!("{x},{}\n", p.y-graph.y_origin).as_bytes()); } let _ = file.flush(); let now = Local::now(); let date_string = format!( "{}-{}-{}-{}-{}-{}-{}", now.year(), now.month(), now.day(), now.hour(), now.minute(), now.second(), now.timestamp_subsec_millis() ); if let Ok(out_file) = OpenOptions::new() .write(true) .append(false) .truncate(true) .create(true) .open(format!("dump-{date_string}.png")) { let _ = Command::new("gnuplot") .stdin(file) .arg("template.gnuplot") .stdout(out_file) .spawn(); } } } if is_key_pressed(KeyCode::F3) { show_debug = !show_debug; } let (_, my) = mouse_wheel(); graph.px_per_s += 10. * my; // toggle debug box if show_debug { let mut debug = DebugWindow::new(font.as_ref()); let params = debug.get_params(); // Debug cursor information let (mouse_x, mouse_y) = mouse_position(); let text = &format!("x{mouse_x}, y{}", -(mouse_y - graph.y_origin)); let size = measure_text(text, params.font, params.font_size, params.font_scale); draw_rectangle( mouse_x, mouse_y + (size.offset_y / 4.), size.width, -(size.height), BLUE, ); draw_text_ex(text, mouse_x, mouse_y, params); // Debug textbox debug.add_line(format!( "FPS {:04}, Frametime {:05.2}ms", get_fps(), frame_delta_time * 1000.0 )); debug.add_line(format!( "Tail Length {TAIL_LEN}, px/s {:.2}", graph.px_per_s )); debug.add_line(format!("Cursor Pos {:?}", mouse_position())); if let Some(port_name) = unsafe { PORT.clone() } { debug.add_line(format!("Serial Port {port_name}, Baud Rate {BAUD_RATE}")); } debug.draw(); } next_frame().await; } } type INT = i32; fn get_serial_port_or_demo() -> (Receiver, JoinHandle<()>) { let (tx, rx): (Sender, Receiver) = mpsc::channel(); let handle = match serialport::available_ports() { Ok(ok) => { let x = ok .iter() .filter(|x| x.port_name.contains("ACM")) .take(1) .collect::>(); if x.len() > 0 { let port = x[0]; unsafe { PORT = Some(port.port_name.clone()); } let mut x = serialport::new(port.port_name.clone(), BAUD_RATE) .timeout(Duration::from_millis(10)) .open() .expect("Failed to open port"); let handle = thread::spawn(move || loop { let mut serial_buf: Vec = vec![0; 8]; if let Ok(x) = x.read(serial_buf.as_mut_slice()) { for i in &serial_buf[..x - 1] { // this data may need to be cleansed let _err = tx.send(i.clone() as INT); } } }); handle } else { dummy_port(tx) } } Err(_err) => dummy_port(tx), }; (rx, handle) } fn dummy_port(tx: Sender) -> JoinHandle<()> { let handle = thread::spawn(move || loop { let a: u8 = std::random::random(..); // center around y origin let a: INT = a as INT - ((u8::MAX / 2) as INT); let _err = tx.send(a); thread::sleep(Duration::from_millis(10)) }); handle }