use super::style::{AxisStyle, SeriesStyle};
use astrelis_render::Color;
use glam::Vec2;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct AxisId(pub u32);
impl AxisId {
pub const X_PRIMARY: AxisId = AxisId(0);
pub const Y_PRIMARY: AxisId = AxisId(1);
pub const X_SECONDARY: AxisId = AxisId(2);
pub const Y_SECONDARY: AxisId = AxisId(3);
pub fn custom(id: u32) -> Self {
Self(id + 4) }
pub fn from_name(name: &str) -> Self {
const FNV_OFFSET_BASIS: u32 = 2166136261;
const FNV_PRIME: u32 = 16777619;
let mut hash = FNV_OFFSET_BASIS;
for byte in name.bytes() {
hash ^= u32::from(byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
Self(hash | 0x8000_0000)
}
pub fn is_standard(&self) -> bool {
self.0 < 4
}
pub fn is_custom(&self) -> bool {
!self.is_standard()
}
pub fn raw(&self) -> u32 {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AxisPosition {
#[default]
Left,
Right,
Top,
Bottom,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AxisOrientation {
#[default]
Horizontal,
Vertical,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct SeriesId(pub u32);
impl SeriesId {
pub fn from_index(index: usize) -> Self {
Self(index as u32)
}
pub fn from_name(name: &str) -> Self {
const FNV_OFFSET_BASIS: u32 = 2166136261;
const FNV_PRIME: u32 = 16777619;
let mut hash = FNV_OFFSET_BASIS;
for byte in name.bytes() {
hash ^= u32::from(byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
Self(hash)
}
pub fn raw(&self) -> u32 {
self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct DataPoint {
pub x: f64,
pub y: f64,
}
impl DataPoint {
pub fn new(x: f64, y: f64) -> Self {
Self { x, y }
}
}
impl From<(f64, f64)> for DataPoint {
fn from((x, y): (f64, f64)) -> Self {
Self { x, y }
}
}
impl From<(f32, f32)> for DataPoint {
fn from((x, y): (f32, f32)) -> Self {
Self {
x: x as f64,
y: y as f64,
}
}
}
#[derive(Debug, Clone)]
pub struct Series {
pub name: String,
pub data: Vec<DataPoint>,
pub style: SeriesStyle,
pub x_axis: AxisId,
pub y_axis: AxisId,
}
impl Series {
pub fn new(name: impl Into<String>, data: Vec<DataPoint>, style: SeriesStyle) -> Self {
Self {
name: name.into(),
data,
style,
x_axis: AxisId::X_PRIMARY,
y_axis: AxisId::Y_PRIMARY,
}
}
pub fn from_tuples<T: Into<DataPoint> + Copy>(
name: impl Into<String>,
data: &[T],
style: SeriesStyle,
) -> Self {
Self {
name: name.into(),
data: data.iter().map(|&d| d.into()).collect(),
style,
x_axis: AxisId::X_PRIMARY,
y_axis: AxisId::Y_PRIMARY,
}
}
pub fn with_axes(mut self, x_axis: AxisId, y_axis: AxisId) -> Self {
self.x_axis = x_axis;
self.y_axis = y_axis;
self
}
pub fn bounds(&self) -> Option<(DataPoint, DataPoint)> {
if self.data.is_empty() {
return None;
}
let mut min = DataPoint::new(f64::INFINITY, f64::INFINITY);
let mut max = DataPoint::new(f64::NEG_INFINITY, f64::NEG_INFINITY);
for p in &self.data {
min.x = min.x.min(p.x);
min.y = min.y.min(p.y);
max.x = max.x.max(p.x);
max.y = max.y.max(p.y);
}
Some((min, max))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ChartType {
#[default]
Line,
Bar,
Scatter,
Area,
}
#[derive(Debug, Clone)]
pub struct Axis {
pub id: AxisId,
pub label: Option<String>,
pub min: Option<f64>,
pub max: Option<f64>,
pub tick_count: usize,
pub grid_lines: bool,
pub style: AxisStyle,
pub position: AxisPosition,
pub orientation: AxisOrientation,
pub visible: bool,
pub custom_ticks: Option<Vec<(f64, String)>>,
}
impl Default for Axis {
fn default() -> Self {
Self {
id: AxisId::default(),
label: None,
min: None,
max: None,
tick_count: 5,
grid_lines: true,
style: AxisStyle::default(),
position: AxisPosition::Left,
orientation: AxisOrientation::Vertical,
visible: true,
custom_ticks: None,
}
}
}
impl Axis {
pub fn x() -> Self {
Self {
id: AxisId::X_PRIMARY,
orientation: AxisOrientation::Horizontal,
position: AxisPosition::Bottom,
..Default::default()
}
}
pub fn y() -> Self {
Self {
id: AxisId::Y_PRIMARY,
orientation: AxisOrientation::Vertical,
position: AxisPosition::Left,
..Default::default()
}
}
pub fn x_secondary() -> Self {
Self {
id: AxisId::X_SECONDARY,
orientation: AxisOrientation::Horizontal,
position: AxisPosition::Top,
..Default::default()
}
}
pub fn y_secondary() -> Self {
Self {
id: AxisId::Y_SECONDARY,
orientation: AxisOrientation::Vertical,
position: AxisPosition::Right,
..Default::default()
}
}
pub fn new(label: impl Into<String>) -> Self {
Self {
label: Some(label.into()),
..Default::default()
}
}
pub fn with_id(mut self, id: AxisId) -> Self {
self.id = id;
self
}
pub fn with_range(mut self, min: f64, max: f64) -> Self {
self.min = Some(min);
self.max = Some(max);
self
}
pub fn with_ticks(mut self, count: usize) -> Self {
self.tick_count = count;
self
}
pub fn with_custom_ticks(mut self, ticks: Vec<(f64, String)>) -> Self {
self.custom_ticks = Some(ticks);
self
}
pub fn with_grid(mut self, enabled: bool) -> Self {
self.grid_lines = enabled;
self
}
pub fn with_position(mut self, position: AxisPosition) -> Self {
self.position = position;
self
}
pub fn with_visible(mut self, visible: bool) -> Self {
self.visible = visible;
self
}
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LegendPosition {
TopLeft,
#[default]
TopRight,
BottomLeft,
BottomRight,
None,
}
#[derive(Debug, Clone)]
pub struct LegendConfig {
pub position: LegendPosition,
pub padding: f32,
}
impl Default for LegendConfig {
fn default() -> Self {
Self {
position: LegendPosition::TopRight,
padding: 10.0,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct BarConfig {
pub bar_width: f32,
pub gap: f32,
}
impl Default for BarConfig {
fn default() -> Self {
Self {
bar_width: 20.0,
gap: 5.0,
}
}
}
#[derive(Debug, Clone)]
pub struct TextAnnotation {
pub text: String,
pub data_position: Option<DataPoint>,
pub pixel_position: Vec2,
pub color: Color,
pub font_size: f32,
pub anchor: Vec2,
pub x_axis: AxisId,
pub y_axis: AxisId,
}
impl TextAnnotation {
pub fn at_data(text: impl Into<String>, x: f64, y: f64) -> Self {
Self {
text: text.into(),
data_position: Some(DataPoint::new(x, y)),
pixel_position: Vec2::ZERO,
color: Color::WHITE,
font_size: 12.0,
anchor: Vec2::new(0.5, 0.5),
x_axis: AxisId::X_PRIMARY,
y_axis: AxisId::Y_PRIMARY,
}
}
pub fn at_pixel(text: impl Into<String>, x: f32, y: f32) -> Self {
Self {
text: text.into(),
data_position: None,
pixel_position: Vec2::new(x, y),
color: Color::WHITE,
font_size: 12.0,
anchor: Vec2::new(0.5, 0.5),
x_axis: AxisId::X_PRIMARY,
y_axis: AxisId::Y_PRIMARY,
}
}
pub fn with_color(mut self, color: Color) -> Self {
self.color = color;
self
}
pub fn with_font_size(mut self, size: f32) -> Self {
self.font_size = size;
self
}
pub fn with_anchor(mut self, anchor: Vec2) -> Self {
self.anchor = anchor;
self
}
}
#[derive(Debug, Clone)]
pub struct LineAnnotation {
pub start: DataPoint,
pub end: DataPoint,
pub color: Color,
pub width: f32,
pub dash: Option<f32>,
pub x_axis: AxisId,
pub y_axis: AxisId,
}
impl LineAnnotation {
pub fn horizontal(y: f64, x_min: f64, x_max: f64) -> Self {
Self {
start: DataPoint::new(x_min, y),
end: DataPoint::new(x_max, y),
color: Color::rgba(0.5, 0.5, 0.5, 0.8),
width: 1.0,
dash: None,
x_axis: AxisId::X_PRIMARY,
y_axis: AxisId::Y_PRIMARY,
}
}
pub fn vertical(x: f64, y_min: f64, y_max: f64) -> Self {
Self {
start: DataPoint::new(x, y_min),
end: DataPoint::new(x, y_max),
color: Color::rgba(0.5, 0.5, 0.5, 0.8),
width: 1.0,
dash: None,
x_axis: AxisId::X_PRIMARY,
y_axis: AxisId::Y_PRIMARY,
}
}
pub fn with_color(mut self, color: Color) -> Self {
self.color = color;
self
}
pub fn with_width(mut self, width: f32) -> Self {
self.width = width;
self
}
pub fn with_dash(mut self, dash: f32) -> Self {
self.dash = Some(dash);
self
}
}
#[derive(Debug, Clone)]
pub struct FillRegion {
pub kind: FillRegionKind,
pub color: Color,
pub x_axis: AxisId,
pub y_axis: AxisId,
}
#[derive(Debug, Clone)]
pub enum FillRegionKind {
HorizontalBand { y_min: f64, y_max: f64 },
VerticalBand { x_min: f64, x_max: f64 },
BelowSeries {
series_index: usize,
y_baseline: f64,
},
BetweenSeries {
series_index_1: usize,
series_index_2: usize,
},
Rectangle {
x_min: f64,
y_min: f64,
x_max: f64,
y_max: f64,
},
Polygon { points: Vec<DataPoint> },
}
impl FillRegion {
pub fn horizontal_band(y_min: f64, y_max: f64, color: Color) -> Self {
Self {
kind: FillRegionKind::HorizontalBand { y_min, y_max },
color,
x_axis: AxisId::X_PRIMARY,
y_axis: AxisId::Y_PRIMARY,
}
}
pub fn vertical_band(x_min: f64, x_max: f64, color: Color) -> Self {
Self {
kind: FillRegionKind::VerticalBand { x_min, x_max },
color,
x_axis: AxisId::X_PRIMARY,
y_axis: AxisId::Y_PRIMARY,
}
}
pub fn below_series(series_index: usize, y_baseline: f64, color: Color) -> Self {
Self {
kind: FillRegionKind::BelowSeries {
series_index,
y_baseline,
},
color,
x_axis: AxisId::X_PRIMARY,
y_axis: AxisId::Y_PRIMARY,
}
}
pub fn between_series(series_index_1: usize, series_index_2: usize, color: Color) -> Self {
Self {
kind: FillRegionKind::BetweenSeries {
series_index_1,
series_index_2,
},
color,
x_axis: AxisId::X_PRIMARY,
y_axis: AxisId::Y_PRIMARY,
}
}
pub fn rectangle(x_min: f64, y_min: f64, x_max: f64, y_max: f64, color: Color) -> Self {
Self {
kind: FillRegionKind::Rectangle {
x_min,
y_min,
x_max,
y_max,
},
color,
x_axis: AxisId::X_PRIMARY,
y_axis: AxisId::Y_PRIMARY,
}
}
pub fn polygon(points: Vec<DataPoint>, color: Color) -> Self {
Self {
kind: FillRegionKind::Polygon { points },
color,
x_axis: AxisId::X_PRIMARY,
y_axis: AxisId::Y_PRIMARY,
}
}
pub fn with_axes(mut self, x_axis: AxisId, y_axis: AxisId) -> Self {
self.x_axis = x_axis;
self.y_axis = y_axis;
self
}
}
#[derive(Debug, Clone)]
pub struct ChartTitle {
pub text: String,
pub font_size: f32,
pub color: Color,
pub position: TitlePosition,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TitlePosition {
#[default]
Top,
Bottom,
Left,
Right,
}
impl ChartTitle {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
font_size: 16.0,
color: Color::WHITE,
position: TitlePosition::Top,
}
}
pub fn with_font_size(mut self, size: f32) -> Self {
self.font_size = size;
self
}
pub fn with_color(mut self, color: Color) -> Self {
self.color = color;
self
}
pub fn with_position(mut self, position: TitlePosition) -> Self {
self.position = position;
self
}
}
#[derive(Debug, Clone)]
pub struct InteractiveState {
pub pan_offset: Vec2,
pub zoom: Vec2,
pub pan_enabled: bool,
pub zoom_enabled: bool,
pub zoom_min: f32,
pub zoom_max: f32,
pub hovered_point: Option<(usize, usize)>,
pub selected_points: Vec<(usize, usize)>,
pub is_dragging: bool,
pub drag_start: Option<Vec2>,
}
impl Default for InteractiveState {
fn default() -> Self {
Self {
pan_offset: Vec2::ZERO,
zoom: Vec2::ONE,
pan_enabled: true,
zoom_enabled: true,
zoom_min: 0.1,
zoom_max: 10.0,
hovered_point: None,
selected_points: Vec::new(),
is_dragging: false,
drag_start: None,
}
}
}
impl InteractiveState {
pub fn reset(&mut self) {
self.pan_offset = Vec2::ZERO;
self.zoom = Vec2::ONE;
}
pub fn zoom_by(&mut self, factor: f32) {
self.zoom =
(self.zoom * factor).clamp(Vec2::splat(self.zoom_min), Vec2::splat(self.zoom_max));
}
pub fn zoom_xy(&mut self, factor_x: f32, factor_y: f32) {
self.zoom.x = (self.zoom.x * factor_x).clamp(self.zoom_min, self.zoom_max);
self.zoom.y = (self.zoom.y * factor_y).clamp(self.zoom_min, self.zoom_max);
}
pub fn zoom_x(&mut self, factor: f32) {
self.zoom.x = (self.zoom.x * factor).clamp(self.zoom_min, self.zoom_max);
}
pub fn zoom_y(&mut self, factor: f32) {
self.zoom.y = (self.zoom.y * factor).clamp(self.zoom_min, self.zoom_max);
}
pub fn zoom_at_normalized(&mut self, normalized_center: Vec2, factor: f32) {
let old_zoom = self.zoom;
let new_zoom =
(self.zoom * factor).clamp(Vec2::splat(self.zoom_min), Vec2::splat(self.zoom_max));
if new_zoom == old_zoom {
return;
}
let offset_from_center = normalized_center - Vec2::splat(0.5);
let zoom_diff = Vec2::new(
1.0 / old_zoom.x - 1.0 / new_zoom.x,
1.0 / old_zoom.y - 1.0 / new_zoom.y,
);
self.pan_offset += offset_from_center * zoom_diff * 2.0;
self.zoom = new_zoom;
}
pub fn zoom_at(&mut self, _center: Vec2, factor: f32) {
self.zoom_by(factor);
}
pub fn pan(&mut self, delta: Vec2) {
if self.pan_enabled {
self.pan_offset += delta;
}
}
}
#[derive(Debug, Clone)]
pub struct Chart {
pub chart_type: ChartType,
pub series: Vec<Series>,
pub axes: Vec<Axis>,
pub title: Option<ChartTitle>,
pub subtitle: Option<ChartTitle>,
pub legend: Option<LegendConfig>,
pub background_color: Color,
pub bar_config: BarConfig,
pub padding: f32,
pub text_annotations: Vec<TextAnnotation>,
pub line_annotations: Vec<LineAnnotation>,
pub fill_regions: Vec<FillRegion>,
pub interactive: InteractiveState,
pub show_crosshair: bool,
pub show_tooltips: bool,
}
impl Default for Chart {
fn default() -> Self {
Self {
chart_type: ChartType::Line,
series: Vec::new(),
axes: vec![Axis::x(), Axis::y()],
title: None,
subtitle: None,
legend: Some(LegendConfig::default()),
background_color: Color::rgba(0.12, 0.12, 0.14, 1.0),
bar_config: BarConfig::default(),
padding: 50.0,
text_annotations: Vec::new(),
line_annotations: Vec::new(),
fill_regions: Vec::new(),
interactive: InteractiveState::default(),
show_crosshair: false,
show_tooltips: true,
}
}
}
impl Chart {
pub fn append_data(&mut self, series_idx: usize, points: &[DataPoint]) {
if let Some(series) = self.series.get_mut(series_idx) {
series.data.extend_from_slice(points);
}
}
pub fn push_point(&mut self, series_idx: usize, point: DataPoint, max_points: Option<usize>) {
if let Some(series) = self.series.get_mut(series_idx) {
series.data.push(point);
if let Some(max) = max_points
&& series.data.len() > max
{
let excess = series.data.len() - max;
series.data.drain(..excess);
}
}
}
pub fn set_data(&mut self, series_idx: usize, data: Vec<DataPoint>) {
if let Some(series) = self.series.get_mut(series_idx) {
series.data = data;
}
}
pub fn clear_data(&mut self, series_idx: usize) {
if let Some(series) = self.series.get_mut(series_idx) {
series.data.clear();
}
}
pub fn series_mut(&mut self, series_idx: usize) -> Option<&mut Series> {
self.series.get_mut(series_idx)
}
pub fn series_len(&self, series_idx: usize) -> usize {
self.series
.get(series_idx)
.map(|s| s.data.len())
.unwrap_or(0)
}
pub fn total_points(&self) -> usize {
self.series.iter().map(|s| s.data.len()).sum()
}
pub fn get_axis(&self, id: AxisId) -> Option<&Axis> {
self.axes.iter().find(|a| a.id == id)
}
pub fn get_axis_mut(&mut self, id: AxisId) -> Option<&mut Axis> {
self.axes.iter_mut().find(|a| a.id == id)
}
pub fn set_axis(&mut self, axis: Axis) {
if let Some(existing) = self.axes.iter_mut().find(|a| a.id == axis.id) {
*existing = axis;
} else {
self.axes.push(axis);
}
}
pub fn x_axis(&self) -> Option<&Axis> {
self.get_axis(AxisId::X_PRIMARY)
}
pub fn y_axis(&self) -> Option<&Axis> {
self.get_axis(AxisId::Y_PRIMARY)
}
pub fn data_bounds_for_axis(&self, axis_id: AxisId) -> Option<(f64, f64)> {
let mut min = f64::INFINITY;
let mut max = f64::NEG_INFINITY;
let mut has_data = false;
for series in &self.series {
let is_x_axis = series.x_axis == axis_id;
let is_y_axis = series.y_axis == axis_id;
if !is_x_axis && !is_y_axis {
continue;
}
if let Some((series_min, series_max)) = series.bounds() {
has_data = true;
if is_x_axis {
min = min.min(series_min.x);
max = max.max(series_max.x);
} else {
min = min.min(series_min.y);
max = max.max(series_max.y);
}
}
}
if has_data { Some((min, max)) } else { None }
}
pub fn data_bounds(&self) -> Option<(DataPoint, DataPoint)> {
let mut combined_min = DataPoint::new(f64::INFINITY, f64::INFINITY);
let mut combined_max = DataPoint::new(f64::NEG_INFINITY, f64::NEG_INFINITY);
let mut has_data = false;
for series in &self.series {
if let Some((min, max)) = series.bounds() {
has_data = true;
combined_min.x = combined_min.x.min(min.x);
combined_min.y = combined_min.y.min(min.y);
combined_max.x = combined_max.x.max(max.x);
combined_max.y = combined_max.y.max(max.y);
}
}
if has_data {
Some((combined_min, combined_max))
} else {
None
}
}
pub fn axis_range(&self, axis_id: AxisId) -> (f64, f64) {
let axis = self.get_axis(axis_id);
let bounds = self.data_bounds_for_axis(axis_id);
let (data_min, data_max) = bounds.unwrap_or((0.0, 1.0));
let min = axis.and_then(|a| a.min).unwrap_or(data_min);
let max = axis.and_then(|a| a.max).unwrap_or(data_max);
let zoom = if axis.map(|a| a.orientation) == Some(AxisOrientation::Horizontal) {
self.interactive.zoom.x
} else {
self.interactive.zoom.y
};
let pan = if axis.map(|a| a.orientation) == Some(AxisOrientation::Horizontal) {
self.interactive.pan_offset.x as f64
} else {
self.interactive.pan_offset.y as f64
};
let range = max - min;
let zoomed_range = range / zoom as f64;
let center = (min + max) / 2.0 + pan;
(center - zoomed_range / 2.0, center + zoomed_range / 2.0)
}
pub fn x_range(&self) -> (f64, f64) {
self.axis_range(AxisId::X_PRIMARY)
}
pub fn y_range(&self) -> (f64, f64) {
self.axis_range(AxisId::Y_PRIMARY)
}
}