diff --git a/src/component/axis.rs b/src/component/axis.rs index c1abee5..6eecefc 100644 --- a/src/component/axis.rs +++ b/src/component/axis.rs @@ -1,11 +1,390 @@ +//! Chart axis component for rendering axis lines, ticks, and labels + +use makepad_widgets::*; +use crate::scale::{Scale, Tick}; +use crate::core::TickOptions; + /// Chart axis component for rendering axis lines and labels -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug)] pub struct ChartAxis { - // Placeholder - to be implemented + /// Axis orientation + orientation: AxisOrientation, + /// Axis position + position: AxisPosition, + /// Tick options + tick_options: TickOptions, + /// Label formatter + label_formatter: Option String + Send + Sync>>, + /// Axis line color + line_color: Vec4, + /// Tick color + tick_color: Vec4, + /// Label color + label_color: Vec4, + /// Line width + line_width: f64, + /// Tick length + tick_length: f64, + /// Label font size + label_font_size: f64, + /// Show axis line + show_line: bool, + /// Show ticks + show_ticks: bool, + /// Show labels + show_labels: bool, + /// Padding between axis and labels + label_padding: f64, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub enum AxisOrientation { + #[default] + Horizontal, // X axis + Vertical, // Y axis +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub enum AxisPosition { + #[default] + Bottom, // For horizontal axis + Top, // For horizontal axis + Left, // For vertical axis + Right, // For vertical axis +} + +impl Default for ChartAxis { + fn default() -> Self { + Self::new() + } } impl ChartAxis { + /// Create a new chart axis + pub fn new() -> Self { + Self { + orientation: AxisOrientation::Horizontal, + position: AxisPosition::Bottom, + tick_options: TickOptions::default(), + label_formatter: None, + line_color: vec4(0.7, 0.7, 0.7, 1.0), + tick_color: vec4(0.7, 0.7, 0.7, 1.0), + label_color: vec4(0.3, 0.3, 0.3, 1.0), + line_width: 1.0, + tick_length: 5.0, + label_font_size: 10.0, + show_line: true, + show_ticks: true, + show_labels: true, + label_padding: 5.0, + } + } + + /// Set axis orientation + pub fn with_orientation(mut self, orientation: AxisOrientation) -> Self { + self.orientation = orientation; + self + } + + /// Set axis position + pub fn with_position(mut self, position: AxisPosition) -> Self { + self.position = position; + self + } + + /// Set tick options + pub fn with_tick_options(mut self, options: TickOptions) -> Self { + self.tick_options = options; + self + } + + /// Set custom label formatter + pub fn with_label_formatter String + Send + Sync + 'static>(mut self, formatter: F) -> Self { + self.label_formatter = Some(Box::new(formatter)); + self + } + + /// Set line color + pub fn with_line_color(mut self, color: Vec4) -> Self { + self.line_color = color; + self + } + + /// Set label color + pub fn with_label_color(mut self, color: Vec4) -> Self { + self.label_color = color; + self + } + + /// Set label font size + pub fn with_label_font_size(mut self, size: f64) -> Self { + self.label_font_size = size; + self + } + + /// Set whether to show the axis line + pub fn show_line(mut self, show: bool) -> Self { + self.show_line = show; + self + } + + /// Set whether to show ticks + pub fn show_ticks(mut self, show: bool) -> Self { + self.show_ticks = show; + self + } + + /// Set whether to show labels + pub fn show_labels(mut self, show: bool) -> Self { + self.show_labels = show; + self + } + + /// Format a tick value using the custom formatter or default + fn format_value(&self, value: f64) -> String { + if let Some(ref formatter) = self.label_formatter { + formatter(value) + } else { + format_number_default(value) + } + } + + /// Render axis labels for given ticks + pub fn render_labels( + &self, + cx: &mut Cx2d, + ticks: &[Tick], + chart_area: Rect, + scale: &dyn Scale, + ) { + if !self.show_labels || ticks.is_empty() { + return; + } + + let text_style = TextStyle { + font_size: self.label_font_size, + ..Default::default() + }; + + match self.orientation { + AxisOrientation::Horizontal => { + self.render_horizontal_labels(cx, ticks, chart_area, scale, text_style); + } + AxisOrientation::Vertical => { + self.render_vertical_labels(cx, ticks, chart_area, scale, text_style); + } + } + } + + fn render_horizontal_labels( + &self, + cx: &mut Cx2d, + ticks: &[Tick], + chart_area: Rect, + scale: &dyn Scale, + text_style: TextStyle, + ) { + let y = match self.position { + AxisPosition::Bottom => chart_area.bottom + self.tick_length + self.label_padding, + AxisPosition::Top => chart_area.top - self.tick_length - self.label_padding - self.label_font_size, + _ => chart_area.bottom + self.tick_length + self.label_padding, + }; + + for tick in ticks { + let x = scale.get_pixel_for_value(tick.value); + + // Center the label on the tick position + let label_x = x - 25.0; // Approximate half width for centering + + cx.draw_text( + &tick.label, + Rect { + pos: dvec2(label_x, y), + size: dvec2(50.0, self.label_font_size + 2.0), + }, + text_style.clone(), + self.label_color, + ); + } + } + + fn render_vertical_labels( + &self, + cx: &mut Cx2d, + ticks: &[Tick], + chart_area: Rect, + scale: &dyn Scale, + text_style: TextStyle, + ) { + for tick in ticks { + let y = scale.get_pixel_for_value(tick.value); + + // Calculate x position based on axis position + let (x, alignment) = match self.position { + AxisPosition::Left => { + let x = chart_area.left - self.tick_length - self.label_padding - 40.0; + (x, LabelAlignment::Right) + } + AxisPosition::Right => { + let x = chart_area.right + self.tick_length + self.label_padding; + (x, LabelAlignment::Left) + } + _ => { + let x = chart_area.left - self.tick_length - self.label_padding - 40.0; + (x, LabelAlignment::Right) + } + }; + + // Center vertically on the tick position + let label_y = y - self.label_font_size / 2.0; + + cx.draw_text( + &tick.label, + Rect { + pos: dvec2(x, label_y), + size: dvec2(40.0, self.label_font_size + 2.0), + }, + text_style.clone(), + self.label_color, + ); + } + } + + /// Render axis line + pub fn render_line(&self, cx: &mut Cx2d, chart_area: Rect) { + if !self.show_line { + return; + } + + // The line rendering would be done by the grid element + // This is a placeholder for future line-specific rendering + let _ = (cx, chart_area); + } +} + +/// Label alignment for text positioning +#[derive(Clone, Debug, Default)] +pub enum LabelAlignment { + #[default] + Center, + Left, + Right, +} + +/// Format a number with reasonable precision +fn format_number_default(value: f64) -> String { + if value == 0.0 { + return "0".to_string(); + } + + let abs_value = value.abs(); + + if abs_value >= 1_000_000.0 { + format!("{:.1}M", value / 1_000_000.0) + } else if abs_value >= 1_000.0 { + format!("{:.1}K", value / 1_000.0) + } else if abs_value >= 1.0 { + // Check if it's a whole number + if value.fract() == 0.0 { + format!("{}", value as i64) + } else { + format!("{:.1}", value) + } + } else if abs_value >= 0.01 { + format!("{:.2}", value) + } else { + format!("{:.3}", value) + } +} + +/// Axis renderer for drawing complete axes with labels +pub struct AxisRenderer { + /// X axis configuration + pub x_axis: ChartAxis, + /// Y axis configuration + pub y_axis: ChartAxis, +} + +impl AxisRenderer { + /// Create a new axis renderer with default settings pub fn new() -> Self { - Self {} + Self { + x_axis: ChartAxis::new() + .with_orientation(AxisOrientation::Horizontal) + .with_position(AxisPosition::Bottom), + y_axis: ChartAxis::new() + .with_orientation(AxisOrientation::Vertical) + .with_position(AxisPosition::Left), + } + } + + /// Render both axes + pub fn render( + &self, + cx: &mut Cx2d, + x_ticks: &[Tick], + y_ticks: &[Tick], + chart_area: Rect, + x_scale: &dyn Scale, + y_scale: &dyn Scale, + ) { + self.x_axis.render_labels(cx, x_ticks, chart_area, x_scale); + self.y_axis.render_labels(cx, y_ticks, chart_area, y_scale); + } +} + +impl Default for AxisRenderer { + fn default() -> Self { + Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_chart_axis_new() { + let axis = ChartAxis::new(); + assert!(axis.show_line); + assert!(axis.show_ticks); + assert!(axis.show_labels); + } + + #[test] + fn test_chart_axis_orientation() { + let axis = ChartAxis::new() + .with_orientation(AxisOrientation::Vertical); + assert_eq!(axis.orientation, AxisOrientation::Vertical); + } + + #[test] + fn test_chart_axis_position() { + let axis = ChartAxis::new() + .with_position(AxisPosition::Left); + assert_eq!(axis.position, AxisPosition::Left); + } + + #[test] + fn test_format_number_default() { + assert_eq!(format_number_default(0.0), "0"); + assert_eq!(format_number_default(100.0), "100"); + assert_eq!(format_number_default(1500.0), "1.5K"); + assert_eq!(format_number_default(1_500_000.0), "1.5M"); + assert_eq!(format_number_default(0.5), "0.50"); + } + + #[test] + fn test_axis_renderer_new() { + let renderer = AxisRenderer::new(); + assert_eq!(renderer.x_axis.orientation, AxisOrientation::Horizontal); + assert_eq!(renderer.y_axis.orientation, AxisOrientation::Vertical); + } + + #[test] + fn test_label_formatter() { + let axis = ChartAxis::new() + .with_label_formatter(|v| format!("${:.0}", v)); + assert_eq!(axis.format_value(100.0), "$100"); + } +} \ No newline at end of file diff --git a/src/element/label.rs b/src/element/label.rs new file mode 100644 index 0000000..36130b3 --- /dev/null +++ b/src/element/label.rs @@ -0,0 +1,264 @@ +//! Text label element for axis labels and tick values + +use makepad_widgets::*; + +live_design! { + use link::theme::*; + use link::shaders::*; + use link::widgets::*; + + pub DrawLabel = {{DrawLabel}} { + color: #000000, + text_style: { + font_size: 10.0, + } + } +} + +#[derive(Live, LiveHook)] +pub struct DrawLabel { + #[live] + color: Vec4, + + #[live] + text_style: TextStyle, + + #[rust] + text: String, + + #[rust] + position: DVec2, + + #[rust] + alignment: LabelAlignment, +} + +#[derive(Clone, Debug, Default)] +pub enum LabelAlignment { + #[default] + Center, + Left, + Right, + Top, + Bottom, +} + +impl DrawLabel { + pub fn new() -> Self { + Self { + color: vec4(0.0, 0.0, 0.0, 1.0), + text_style: TextStyle::default(), + text: String::new(), + position: dvec2(0.0, 0.0), + alignment: LabelAlignment::Center, + } + } + + /// Set the label text + pub fn set_text(&mut self, text: impl Into) { + self.text = text.into(); + } + + /// Set the position + pub fn set_position(&mut self, x: f64, y: f64) { + self.position = dvec2(x, y); + } + + /// Set the color + pub fn set_color(&mut self, color: Vec4) { + self.color = color; + } + + /// Set the font size + pub fn set_font_size(&mut self, size: f64) { + self.text_style.font_size = size; + } + + /// Set alignment + pub fn set_alignment(&mut self, alignment: LabelAlignment) { + self.alignment = alignment; + } + + /// Draw the label + pub fn draw_label(&mut self, cx: &mut Cx2d) { + if self.text.is_empty() { + return; + } + + // Calculate text position based on alignment + let (x, y) = match self.alignment { + LabelAlignment::Center => (self.position.x, self.position.y), + LabelAlignment::Left => (self.position.x, self.position.y), + LabelAlignment::Right => (self.position.x, self.position.y), + LabelAlignment::Top => (self.position.x, self.position.y), + LabelAlignment::Bottom => (self.position.x, self.position.y), + }; + + // Use Makepad's text drawing API + let text_rect = Rect { + pos: dvec2(x, y), + size: dvec2(100.0, 20.0), // Default size for text bounds + }; + + // Draw text using the text style + cx.draw_text( + &self.text, + text_rect, + self.text_style.clone(), + self.color, + ); + } +} + +impl Default for DrawLabel { + fn default() -> Self { + Self::new() + } +} + +/// Axis label renderer for drawing axis tick labels +pub struct AxisLabelRenderer { + /// X axis labels + x_labels: Vec, + /// Y axis labels + y_labels: Vec, + /// Label color + color: Vec4, + /// Font size + font_size: f64, + /// Padding from axis + padding: f64, +} + +#[derive(Clone, Debug)] +pub struct AxisLabel { + /// Label text + pub text: String, + /// Position in pixels + pub position: DVec2, + /// Alignment + pub alignment: LabelAlignment, +} + +impl AxisLabelRenderer { + pub fn new() -> Self { + Self { + x_labels: Vec::new(), + y_labels: Vec::new(), + color: vec4(0.3, 0.3, 0.3, 1.0), + font_size: 10.0, + padding: 5.0, + } + } + + /// Set label color + pub fn set_color(&mut self, color: Vec4) { + self.color = color; + } + + /// Set font size + pub fn set_font_size(&mut self, size: f64) { + self.font_size = size; + } + + /// Set padding from axis + pub fn set_padding(&mut self, padding: f64) { + self.padding = padding; + } + + /// Clear all labels + pub fn clear(&mut self) { + self.x_labels.clear(); + self.y_labels.clear(); + } + + /// Add X axis label + pub fn add_x_label(&mut self, text: impl Into, x: f64, y: f64) { + self.x_labels.push(AxisLabel { + text: text.into(), + position: dvec2(x, y), + alignment: LabelAlignment::Top, + }); + } + + /// Add Y axis label + pub fn add_y_label(&mut self, text: impl Into, x: f64, y: f64) { + self.y_labels.push(AxisLabel { + text: text.into(), + position: dvec2(x, y), + alignment: LabelAlignment::Right, + }); + } + + /// Draw all labels + pub fn draw(&self, cx: &mut Cx2d) { + let text_style = TextStyle { + font_size: self.font_size, + ..Default::default() + }; + + // Draw X axis labels + for label in &self.x_labels { + cx.draw_text( + &label.text, + Rect { + pos: label.position, + size: dvec2(50.0, 15.0), + }, + text_style.clone(), + self.color, + ); + } + + // Draw Y axis labels + for label in &self.y_labels { + cx.draw_text( + &label.text, + Rect { + pos: label.position, + size: dvec2(50.0, 15.0), + }, + text_style.clone(), + self.color, + ); + } + } +} + +impl Default for AxisLabelRenderer { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_draw_label_creation() { + let label = DrawLabel::new(); + assert!(label.text.is_empty()); + assert_eq!(label.color, vec4(0.0, 0.0, 0.0, 1.0)); + } + + #[test] + fn test_axis_label_renderer() { + let mut renderer = AxisLabelRenderer::new(); + renderer.add_x_label("Jan", 10.0, 100.0); + renderer.add_y_label("100", 5.0, 50.0); + + assert_eq!(renderer.x_labels.len(), 1); + assert_eq!(renderer.y_labels.len(), 1); + } + + #[test] + fn test_axis_label_renderer_clear() { + let mut renderer = AxisLabelRenderer::new(); + renderer.add_x_label("Jan", 10.0, 100.0); + renderer.clear(); + + assert!(renderer.x_labels.is_empty()); + assert!(renderer.y_labels.is_empty()); + } +} \ No newline at end of file diff --git a/src/element/mod.rs b/src/element/mod.rs index e6600a9..6855aed 100644 --- a/src/element/mod.rs +++ b/src/element/mod.rs @@ -4,6 +4,7 @@ pub mod point; pub mod arc; pub mod triangle; pub mod grid; +pub mod label; pub use bar::*; pub use line::*; @@ -11,6 +12,7 @@ pub use point::*; pub use arc::*; pub use triangle::*; pub use grid::*; +pub use label::*; use makepad_widgets::*; @@ -21,4 +23,5 @@ pub fn live_design(cx: &mut Cx) { arc::live_design(cx); triangle::live_design(cx); grid::live_design(cx); + label::live_design(cx); }