use rdom_tui::Color;
use crate::palette::series_color;
#[derive(Clone, Debug, PartialEq)]
pub struct DataPoint {
pub timestamp: f64,
pub value: f64,
}
impl DataPoint {
pub fn new(timestamp: f64, value: f64) -> Self {
Self { timestamp, value }
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TimeRange {
pub start: f64,
pub end: f64,
}
impl TimeRange {
pub fn new(start: f64, end: f64) -> Self {
Self { start, end }
}
pub fn duration(&self) -> f64 {
self.end - self.start
}
pub fn contains(&self, t: f64) -> bool {
t >= self.start && t <= self.end
}
pub fn overlaps(&self, other: &TimeRange) -> bool {
self.start < other.end && other.start < self.end
}
}
impl Default for TimeRange {
fn default() -> Self {
Self {
start: 0.0,
end: 0.0,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SeriesStyle {
#[default]
Line,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ConnectPolicy {
#[default]
Gap,
}
#[derive(Clone, Debug)]
pub struct Series {
pub name: String,
pub color: Option<Color>,
pub data: Vec<DataPoint>,
pub style: SeriesStyle,
pub connect: ConnectPolicy,
}
impl Series {
pub fn line(name: impl Into<String>, data: Vec<DataPoint>) -> Self {
Self {
name: name.into(),
color: None,
data,
style: SeriesStyle::Line,
connect: ConnectPolicy::Gap,
}
}
pub fn with_color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
}
#[derive(Clone, Debug)]
pub struct Guideline {
pub y_value: f64,
pub color: Color,
pub label: Option<String>,
}
#[derive(Default)]
pub struct YAxisConfig {
pub min: Option<f64>,
pub max: Option<f64>,
pub format: Option<fn(f64) -> String>,
}
#[derive(Default)]
pub struct XAxisConfig {
pub format: Option<fn(f64) -> String>,
}
pub(crate) const DEFAULT_MAX_POINTS: usize = 10_000;
pub(crate) struct SeriesBuffer {
pub name: String,
pub color: Color,
pub style: SeriesStyle,
pub connect: ConnectPolicy,
pub points: Vec<DataPoint>,
pub loaded_ranges: Vec<TimeRange>,
pub max_points: usize,
}
impl SeriesBuffer {
pub fn new(name: String, color: Color, style: SeriesStyle, connect: ConnectPolicy) -> Self {
Self {
name,
color,
style,
connect,
points: Vec::new(),
loaded_ranges: Vec::new(),
max_points: DEFAULT_MAX_POINTS,
}
}
pub fn from_series(s: Series, index: usize) -> Self {
let color = s.color.unwrap_or_else(|| series_color(index));
let mut buf = Self::new(s.name, color, s.style, s.connect);
if !s.data.is_empty() {
buf.push_points(&s.data);
}
buf
}
pub fn push_points(&mut self, points: &[DataPoint]) {
if points.is_empty() {
return;
}
if points.len() == 1 {
let p = &points[0];
let idx = self
.points
.partition_point(|existing| existing.timestamp < p.timestamp);
if idx < self.points.len() && (self.points[idx].timestamp - p.timestamp).abs() < 1e-9 {
self.points[idx].value = p.value;
} else {
self.points.insert(idx, p.clone());
}
} else {
let mut incoming: Vec<DataPoint> = points.to_vec();
incoming.sort_by(|a, b| {
a.timestamp
.partial_cmp(&b.timestamp)
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut merged = Vec::with_capacity(self.points.len() + incoming.len());
let mut i = 0;
let mut j = 0;
while i < self.points.len() && j < incoming.len() {
if (self.points[i].timestamp - incoming[j].timestamp).abs() < 1e-9 {
merged.push(incoming[j].clone()); i += 1;
j += 1;
} else if self.points[i].timestamp < incoming[j].timestamp {
merged.push(self.points[i].clone());
i += 1;
} else {
merged.push(incoming[j].clone());
j += 1;
}
}
merged.extend_from_slice(&self.points[i..]);
merged.extend_from_slice(&incoming[j..]);
self.points = merged;
}
if self.points.len() > self.max_points {
let excess = self.points.len() - self.max_points;
self.points.drain(..excess);
}
}
pub fn mark_loaded(&mut self, range: &TimeRange) {
let insert_at = self.loaded_ranges.partition_point(|r| r.end < range.start);
let mut merged = *range;
let mut remove_end = insert_at;
while remove_end < self.loaded_ranges.len()
&& self.loaded_ranges[remove_end].start <= merged.end
{
merged.start = merged.start.min(self.loaded_ranges[remove_end].start);
merged.end = merged.end.max(self.loaded_ranges[remove_end].end);
remove_end += 1;
}
self.loaded_ranges
.splice(insert_at..remove_end, std::iter::once(merged));
}
pub fn is_loaded(&self, range: &TimeRange) -> bool {
self.loaded_ranges
.iter()
.any(|lr| lr.start <= range.start && lr.end >= range.end)
}
#[cfg(test)]
pub fn points_in_range(&self, range: &TimeRange) -> &[DataPoint] {
let start = self.points.partition_point(|p| p.timestamp < range.start);
let end = self.points.partition_point(|p| p.timestamp <= range.end);
&self.points[start..end]
}
pub fn points_in_range_padded(&self, range: &TimeRange) -> &[DataPoint] {
let start = self.points.partition_point(|p| p.timestamp < range.start);
let end = self.points.partition_point(|p| p.timestamp <= range.end);
let padded_start = start.saturating_sub(1);
let padded_end = (end + 1).min(self.points.len());
&self.points[padded_start..padded_end]
}
pub fn time_extent(&self) -> Option<TimeRange> {
if self.points.is_empty() {
return None;
}
Some(TimeRange::new(
self.points.first().unwrap().timestamp,
self.points.last().unwrap().timestamp,
))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn buf() -> SeriesBuffer {
SeriesBuffer::new(
"test".into(),
series_color(0),
SeriesStyle::Line,
ConnectPolicy::Gap,
)
}
#[test]
fn push_sorts() {
let mut b = buf();
b.push_points(&[
DataPoint::new(3.0, 30.0),
DataPoint::new(1.0, 10.0),
DataPoint::new(2.0, 20.0),
]);
assert_eq!(b.points.len(), 3);
assert_eq!(b.points[0].timestamp, 1.0);
assert_eq!(b.points[2].timestamp, 3.0);
}
#[test]
fn push_deduplicates() {
let mut b = buf();
b.push_points(&[DataPoint::new(1.0, 10.0)]);
b.push_points(&[DataPoint::new(1.0, 99.0)]);
assert_eq!(b.points.len(), 1);
assert_eq!(b.points[0].value, 99.0);
}
#[test]
fn bulk_merge() {
let mut b = buf();
b.push_points(&[
DataPoint::new(1.0, 1.0),
DataPoint::new(3.0, 3.0),
DataPoint::new(5.0, 5.0),
]);
b.push_points(&[DataPoint::new(2.0, 2.0), DataPoint::new(4.0, 4.0)]);
assert_eq!(b.points.len(), 5);
for (i, p) in b.points.iter().enumerate() {
assert_eq!(p.timestamp, (i + 1) as f64);
}
}
#[test]
fn evicts_oldest() {
let mut b = buf();
b.max_points = 3;
b.push_points(&[
DataPoint::new(1.0, 1.0),
DataPoint::new(2.0, 2.0),
DataPoint::new(3.0, 3.0),
DataPoint::new(4.0, 4.0),
DataPoint::new(5.0, 5.0),
]);
assert_eq!(b.points.len(), 3);
assert_eq!(b.points[0].timestamp, 3.0);
assert_eq!(b.points[2].timestamp, 5.0);
}
#[test]
fn points_in_range_inclusive() {
let mut b = buf();
b.push_points(&[
DataPoint::new(1.0, 1.0),
DataPoint::new(2.0, 2.0),
DataPoint::new(3.0, 3.0),
DataPoint::new(4.0, 4.0),
DataPoint::new(5.0, 5.0),
]);
let pts = b.points_in_range(&TimeRange::new(2.0, 4.0));
assert_eq!(pts.len(), 3);
assert_eq!(pts[0].timestamp, 2.0);
assert_eq!(pts[2].timestamp, 4.0);
}
#[test]
fn padded_range_adds_neighbors() {
let mut b = buf();
b.push_points(&[
DataPoint::new(1.0, 1.0),
DataPoint::new(2.0, 2.0),
DataPoint::new(3.0, 3.0),
DataPoint::new(4.0, 4.0),
DataPoint::new(5.0, 5.0),
]);
let pts = b.points_in_range_padded(&TimeRange::new(2.0, 4.0));
assert_eq!(pts.first().unwrap().timestamp, 1.0);
assert_eq!(pts.last().unwrap().timestamp, 5.0);
}
#[test]
fn loaded_ranges_merge() {
let mut b = buf();
b.mark_loaded(&TimeRange::new(0.0, 10.0));
assert!(b.is_loaded(&TimeRange::new(2.0, 8.0)));
assert!(!b.is_loaded(&TimeRange::new(5.0, 15.0)));
b.mark_loaded(&TimeRange::new(8.0, 20.0));
assert!(b.is_loaded(&TimeRange::new(0.0, 20.0)));
assert_eq!(b.loaded_ranges.len(), 1);
}
#[test]
fn time_extent_spans_points() {
let mut b = buf();
assert_eq!(b.time_extent(), None);
b.push_points(&[DataPoint::new(3.0, 1.0), DataPoint::new(9.0, 2.0)]);
assert_eq!(b.time_extent(), Some(TimeRange::new(3.0, 9.0)));
}
#[test]
fn from_series_assigns_palette_color() {
let s = Series::line("CPU", vec![DataPoint::new(1.0, 50.0)]);
let b = SeriesBuffer::from_series(s, 1);
assert_eq!(b.color, series_color(1));
assert_eq!(b.points.len(), 1);
}
#[test]
fn from_series_respects_explicit_color() {
let s = Series::line("CPU", vec![]).with_color(Color::Rgb(1, 2, 3));
let b = SeriesBuffer::from_series(s, 0);
assert_eq!(b.color, Color::Rgb(1, 2, 3));
}
}