Skip to main content

presentar_widgets/
chart.rs

1//! `Chart` widget for data visualization.
2
3use presentar_core::{
4    widget::{AccessibleRole, LayoutResult, TextStyle},
5    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Point, Rect,
6    Size, TypeId, Widget,
7};
8use serde::{Deserialize, Serialize};
9use std::any::Any;
10use std::time::Duration;
11
12/// Chart type variants.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
14pub enum ChartType {
15    /// Line chart
16    #[default]
17    Line,
18    /// Bar chart
19    Bar,
20    /// Scatter plot
21    Scatter,
22    /// Area chart
23    Area,
24    /// Pie chart
25    Pie,
26    /// Histogram
27    Histogram,
28    /// Heatmap - displays matrix data with color encoding
29    Heatmap,
30    /// Box plot - displays statistical distributions
31    BoxPlot,
32}
33
34/// A single data series for the chart.
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
36pub struct DataSeries {
37    /// Series name/label
38    pub name: String,
39    /// Data points (x, y)
40    pub points: Vec<(f64, f64)>,
41    /// Series color
42    pub color: Color,
43    /// Line width (for line/area charts)
44    pub line_width: f32,
45    /// Point size (for scatter/line charts)
46    pub point_size: f32,
47    /// Whether to show points
48    pub show_points: bool,
49    /// Whether to fill area under line
50    pub fill: bool,
51}
52
53impl DataSeries {
54    /// Create a new data series.
55    #[must_use]
56    pub fn new(name: impl Into<String>) -> Self {
57        Self {
58            name: name.into(),
59            points: Vec::new(),
60            color: Color::new(0.2, 0.47, 0.96, 1.0),
61            line_width: 2.0,
62            point_size: 4.0,
63            show_points: true,
64            fill: false,
65        }
66    }
67
68    /// Add a data point.
69    #[must_use]
70    pub fn point(mut self, x: f64, y: f64) -> Self {
71        self.points.push((x, y));
72        self
73    }
74
75    /// Add multiple data points.
76    #[must_use]
77    pub fn points(mut self, points: impl IntoIterator<Item = (f64, f64)>) -> Self {
78        self.points.extend(points);
79        self
80    }
81
82    /// Set series color.
83    #[must_use]
84    pub const fn color(mut self, color: Color) -> Self {
85        self.color = color;
86        self
87    }
88
89    /// Set line width.
90    #[must_use]
91    pub fn line_width(mut self, width: f32) -> Self {
92        self.line_width = width.max(0.5);
93        self
94    }
95
96    /// Set point size.
97    #[must_use]
98    pub fn point_size(mut self, size: f32) -> Self {
99        self.point_size = size.max(1.0);
100        self
101    }
102
103    /// Set whether to show points.
104    #[must_use]
105    pub const fn show_points(mut self, show: bool) -> Self {
106        self.show_points = show;
107        self
108    }
109
110    /// Set whether to fill area.
111    #[must_use]
112    pub const fn fill(mut self, fill: bool) -> Self {
113        self.fill = fill;
114        self
115    }
116
117    /// Get min/max X values.
118    #[must_use]
119    pub fn x_range(&self) -> Option<(f64, f64)> {
120        if self.points.is_empty() {
121            return None;
122        }
123        let min = self
124            .points
125            .iter()
126            .map(|(x, _)| *x)
127            .fold(f64::INFINITY, f64::min);
128        let max = self
129            .points
130            .iter()
131            .map(|(x, _)| *x)
132            .fold(f64::NEG_INFINITY, f64::max);
133        Some((min, max))
134    }
135
136    /// Get min/max Y values.
137    #[must_use]
138    pub fn y_range(&self) -> Option<(f64, f64)> {
139        if self.points.is_empty() {
140            return None;
141        }
142        let min = self
143            .points
144            .iter()
145            .map(|(_, y)| *y)
146            .fold(f64::INFINITY, f64::min);
147        let max = self
148            .points
149            .iter()
150            .map(|(_, y)| *y)
151            .fold(f64::NEG_INFINITY, f64::max);
152        Some((min, max))
153    }
154}
155
156/// Axis configuration.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct Axis {
159    /// Axis label
160    pub label: Option<String>,
161    /// Minimum value (auto if None)
162    pub min: Option<f64>,
163    /// Maximum value (auto if None)
164    pub max: Option<f64>,
165    /// Number of grid lines
166    pub grid_lines: usize,
167    /// Show grid
168    pub show_grid: bool,
169    /// Axis color
170    pub color: Color,
171    /// Grid color
172    pub grid_color: Color,
173}
174
175impl Default for Axis {
176    fn default() -> Self {
177        Self {
178            label: None,
179            min: None,
180            max: None,
181            grid_lines: 5,
182            show_grid: true,
183            color: Color::new(0.3, 0.3, 0.3, 1.0),
184            grid_color: Color::new(0.9, 0.9, 0.9, 1.0),
185        }
186    }
187}
188
189impl Axis {
190    /// Create a new axis.
191    #[must_use]
192    pub fn new() -> Self {
193        Self::default()
194    }
195
196    /// Set axis label.
197    #[must_use]
198    pub fn label(mut self, label: impl Into<String>) -> Self {
199        self.label = Some(label.into());
200        self
201    }
202
203    /// Set minimum value.
204    #[must_use]
205    pub const fn min(mut self, min: f64) -> Self {
206        self.min = Some(min);
207        self
208    }
209
210    /// Set maximum value.
211    #[must_use]
212    pub const fn max(mut self, max: f64) -> Self {
213        self.max = Some(max);
214        self
215    }
216
217    /// Set range.
218    #[must_use]
219    pub const fn range(mut self, min: f64, max: f64) -> Self {
220        self.min = Some(min);
221        self.max = Some(max);
222        self
223    }
224
225    /// Set number of grid lines.
226    #[must_use]
227    pub fn grid_lines(mut self, count: usize) -> Self {
228        self.grid_lines = count.max(2);
229        self
230    }
231
232    /// Set whether to show grid.
233    #[must_use]
234    pub const fn show_grid(mut self, show: bool) -> Self {
235        self.show_grid = show;
236        self
237    }
238
239    /// Set axis color.
240    #[must_use]
241    pub const fn color(mut self, color: Color) -> Self {
242        self.color = color;
243        self
244    }
245
246    /// Set grid color.
247    #[must_use]
248    pub const fn grid_color(mut self, color: Color) -> Self {
249        self.grid_color = color;
250        self
251    }
252}
253
254/// Legend position.
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
256pub enum LegendPosition {
257    /// No legend
258    None,
259    /// Top right (default)
260    #[default]
261    TopRight,
262    /// Top left
263    TopLeft,
264    /// Bottom right
265    BottomRight,
266    /// Bottom left
267    BottomLeft,
268}
269
270/// `Chart` widget for data visualization.
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct Chart {
273    /// Chart type
274    kind: ChartType,
275    /// Data series
276    series: Vec<DataSeries>,
277    /// Chart title
278    title: Option<String>,
279    /// X axis configuration
280    x_axis: Axis,
281    /// Y axis configuration
282    y_axis: Axis,
283    /// Legend position
284    legend: LegendPosition,
285    /// Background color
286    background: Color,
287    /// Padding around chart area
288    padding: f32,
289    /// Width
290    width: Option<f32>,
291    /// Height
292    height: Option<f32>,
293    /// Accessible name
294    accessible_name_value: Option<String>,
295    /// Test ID
296    test_id_value: Option<String>,
297    /// Cached bounds
298    #[serde(skip)]
299    bounds: Rect,
300}
301
302impl Default for Chart {
303    fn default() -> Self {
304        Self {
305            kind: ChartType::Line,
306            series: Vec::new(),
307            title: None,
308            x_axis: Axis::default(),
309            y_axis: Axis::default(),
310            legend: LegendPosition::TopRight,
311            background: Color::WHITE,
312            padding: 40.0,
313            width: None,
314            height: None,
315            accessible_name_value: None,
316            test_id_value: None,
317            bounds: Rect::default(),
318        }
319    }
320}
321
322impl Chart {
323    /// Create a new chart.
324    #[must_use]
325    pub fn new() -> Self {
326        Self::default()
327    }
328
329    /// Create a line chart.
330    #[must_use]
331    pub fn line() -> Self {
332        Self::new().chart_type(ChartType::Line)
333    }
334
335    /// Create a bar chart.
336    #[must_use]
337    pub fn bar() -> Self {
338        Self::new().chart_type(ChartType::Bar)
339    }
340
341    /// Create a scatter chart.
342    #[must_use]
343    pub fn scatter() -> Self {
344        Self::new().chart_type(ChartType::Scatter)
345    }
346
347    /// Create an area chart.
348    #[must_use]
349    pub fn area() -> Self {
350        Self::new().chart_type(ChartType::Area)
351    }
352
353    /// Create a pie chart.
354    #[must_use]
355    pub fn pie() -> Self {
356        Self::new().chart_type(ChartType::Pie)
357    }
358
359    /// Create a heatmap chart.
360    #[must_use]
361    pub fn heatmap() -> Self {
362        Self::new().chart_type(ChartType::Heatmap)
363    }
364
365    /// Create a box plot chart.
366    #[must_use]
367    pub fn boxplot() -> Self {
368        Self::new().chart_type(ChartType::BoxPlot)
369    }
370
371    /// Set chart type.
372    #[must_use]
373    pub const fn chart_type(mut self, chart_type: ChartType) -> Self {
374        self.kind = chart_type;
375        self
376    }
377
378    /// Add a data series.
379    #[must_use]
380    pub fn series(mut self, series: DataSeries) -> Self {
381        self.series.push(series);
382        self
383    }
384
385    /// Add multiple data series.
386    #[must_use]
387    pub fn add_series(mut self, series: impl IntoIterator<Item = DataSeries>) -> Self {
388        self.series.extend(series);
389        self
390    }
391
392    /// Set chart title.
393    #[must_use]
394    pub fn title(mut self, title: impl Into<String>) -> Self {
395        self.title = Some(title.into());
396        self
397    }
398
399    /// Set X axis.
400    #[must_use]
401    pub fn x_axis(mut self, axis: Axis) -> Self {
402        self.x_axis = axis;
403        self
404    }
405
406    /// Set Y axis.
407    #[must_use]
408    pub fn y_axis(mut self, axis: Axis) -> Self {
409        self.y_axis = axis;
410        self
411    }
412
413    /// Set legend position.
414    #[must_use]
415    pub const fn legend(mut self, position: LegendPosition) -> Self {
416        self.legend = position;
417        self
418    }
419
420    /// Set background color.
421    #[must_use]
422    pub const fn background(mut self, color: Color) -> Self {
423        self.background = color;
424        self
425    }
426
427    /// Set padding.
428    #[must_use]
429    pub fn padding(mut self, padding: f32) -> Self {
430        self.padding = padding.max(0.0);
431        self
432    }
433
434    /// Set width.
435    #[must_use]
436    pub fn width(mut self, width: f32) -> Self {
437        self.width = Some(width.max(100.0));
438        self
439    }
440
441    /// Set height.
442    #[must_use]
443    pub fn height(mut self, height: f32) -> Self {
444        self.height = Some(height.max(100.0));
445        self
446    }
447
448    /// Set accessible name.
449    #[must_use]
450    pub fn accessible_name(mut self, name: impl Into<String>) -> Self {
451        self.accessible_name_value = Some(name.into());
452        self
453    }
454
455    /// Set test ID.
456    #[must_use]
457    pub fn test_id(mut self, id: impl Into<String>) -> Self {
458        self.test_id_value = Some(id.into());
459        self
460    }
461
462    /// Get chart type.
463    #[must_use]
464    pub const fn get_chart_type(&self) -> ChartType {
465        self.kind
466    }
467
468    /// Get data series.
469    #[must_use]
470    pub fn get_series(&self) -> &[DataSeries] {
471        &self.series
472    }
473
474    /// Get series count.
475    #[must_use]
476    pub fn series_count(&self) -> usize {
477        self.series.len()
478    }
479
480    /// Check if chart has data.
481    #[must_use]
482    pub fn has_data(&self) -> bool {
483        self.series.iter().any(|s| !s.points.is_empty())
484    }
485
486    /// Get title.
487    #[must_use]
488    pub fn get_title(&self) -> Option<&str> {
489        self.title.as_deref()
490    }
491
492    /// Compute data bounds across all series.
493    #[must_use]
494    pub fn data_bounds(&self) -> Option<(f64, f64, f64, f64)> {
495        if !self.has_data() {
496            return None;
497        }
498
499        let mut x_min = f64::INFINITY;
500        let mut x_max = f64::NEG_INFINITY;
501        let mut y_min = f64::INFINITY;
502        let mut y_max = f64::NEG_INFINITY;
503
504        for series in &self.series {
505            if let Some((min, max)) = series.x_range() {
506                x_min = x_min.min(min);
507                x_max = x_max.max(max);
508            }
509            if let Some((min, max)) = series.y_range() {
510                y_min = y_min.min(min);
511                y_max = y_max.max(max);
512            }
513        }
514
515        // Apply axis overrides
516        if let Some(min) = self.x_axis.min {
517            x_min = min;
518        }
519        if let Some(max) = self.x_axis.max {
520            x_max = max;
521        }
522        if let Some(min) = self.y_axis.min {
523            y_min = min;
524        }
525        if let Some(max) = self.y_axis.max {
526            y_max = max;
527        }
528
529        Some((x_min, x_max, y_min, y_max))
530    }
531
532    /// Get plot area (excluding padding and labels).
533    fn plot_area(&self) -> Rect {
534        let title_height = if self.title.is_some() { 30.0 } else { 0.0 };
535        Rect::new(
536            self.bounds.x + self.padding,
537            self.bounds.y + self.padding + title_height,
538            self.padding.mul_add(-2.0, self.bounds.width),
539            self.padding.mul_add(-2.0, self.bounds.height) - title_height,
540        )
541    }
542
543    /// Map data point to screen coordinates.
544    fn map_point(&self, x: f64, y: f64, bounds: &(f64, f64, f64, f64), plot: &Rect) -> Point {
545        let (x_min, x_max, y_min, y_max) = *bounds;
546        let x_range = (x_max - x_min).max(1e-10);
547        let y_range = (y_max - y_min).max(1e-10);
548
549        let px = (((x - x_min) / x_range) as f32).mul_add(plot.width, plot.x);
550        let py = (((y - y_min) / y_range) as f32).mul_add(-plot.height, plot.y + plot.height);
551
552        Point::new(px, py)
553    }
554
555    /// Paint grid lines.
556    fn paint_grid(&self, canvas: &mut dyn Canvas, plot: &Rect, bounds: &(f64, f64, f64, f64)) {
557        let (x_min, x_max, y_min, y_max) = *bounds;
558
559        // Vertical grid lines
560        if self.x_axis.show_grid {
561            for i in 0..=self.x_axis.grid_lines {
562                let t = i as f32 / self.x_axis.grid_lines as f32;
563                let x = t.mul_add(plot.width, plot.x);
564                canvas.draw_line(
565                    Point::new(x, plot.y),
566                    Point::new(x, plot.y + plot.height),
567                    self.x_axis.grid_color,
568                    1.0,
569                );
570            }
571        }
572
573        // Horizontal grid lines
574        if self.y_axis.show_grid {
575            for i in 0..=self.y_axis.grid_lines {
576                let t = i as f32 / self.y_axis.grid_lines as f32;
577                let y = t.mul_add(plot.height, plot.y);
578                canvas.draw_line(
579                    Point::new(plot.x, y),
580                    Point::new(plot.x + plot.width, y),
581                    self.y_axis.grid_color,
582                    1.0,
583                );
584            }
585        }
586
587        // Axis labels
588        let text_style = TextStyle {
589            size: 10.0,
590            color: self.x_axis.color,
591            ..TextStyle::default()
592        };
593
594        // X axis labels
595        for i in 0..=self.x_axis.grid_lines {
596            let t = i as f64 / self.x_axis.grid_lines as f64;
597            let value = t.mul_add(x_max - x_min, x_min);
598            let x = (t as f32).mul_add(plot.width, plot.x);
599            canvas.draw_text(
600                &format!("{value:.1}"),
601                Point::new(x, plot.y + plot.height + 15.0),
602                &text_style,
603            );
604        }
605
606        // Y axis labels
607        for i in 0..=self.y_axis.grid_lines {
608            let t = i as f64 / self.y_axis.grid_lines as f64;
609            let value = t.mul_add(-(y_max - y_min), y_max);
610            let y = (t as f32).mul_add(plot.height, plot.y);
611            canvas.draw_text(
612                &format!("{value:.1}"),
613                Point::new(plot.x - 35.0, y + 4.0),
614                &text_style,
615            );
616        }
617    }
618
619    /// Paint line/area chart.
620    fn paint_line(&self, canvas: &mut dyn Canvas, plot: &Rect, bounds: &(f64, f64, f64, f64)) {
621        for series in &self.series {
622            if series.points.len() < 2 {
623                continue;
624            }
625
626            // Collect points for the path
627            let path_points: Vec<Point> = series
628                .points
629                .iter()
630                .map(|&(x, y)| self.map_point(x, y, bounds, plot))
631                .collect();
632
633            // Draw line using proper path
634            canvas.draw_path(&path_points, series.color, series.line_width);
635
636            // For area charts, fill the area under the line
637            if series.fill {
638                let mut fill_points = path_points.clone();
639                // Add bottom corners
640                if let (Some(first), Some(last)) = (path_points.first(), path_points.last()) {
641                    fill_points.push(Point::new(last.x, plot.y + plot.height));
642                    fill_points.push(Point::new(first.x, plot.y + plot.height));
643                }
644                let mut fill_color = series.color;
645                fill_color.a = 0.3; // Semi-transparent fill
646                canvas.fill_polygon(&fill_points, fill_color);
647            }
648
649            // Draw points as circles
650            if series.show_points {
651                for &(x, y) in &series.points {
652                    let pt = self.map_point(x, y, bounds, plot);
653                    canvas.fill_circle(pt, series.point_size / 2.0, series.color);
654                }
655            }
656        }
657    }
658
659    /// Paint bar chart.
660    fn paint_bar(&self, canvas: &mut dyn Canvas, plot: &Rect, bounds: &(f64, f64, f64, f64)) {
661        let (_, _, y_min, y_max) = *bounds;
662        let y_range = (y_max - y_min).max(1e-10);
663
664        let series_count = self.series.len();
665        if series_count == 0 {
666            return;
667        }
668
669        // Calculate bar width based on number of points
670        let max_points = self
671            .series
672            .iter()
673            .map(|s| s.points.len())
674            .max()
675            .unwrap_or(1);
676        let group_width = plot.width / max_points as f32;
677        let bar_width = (group_width * 0.8) / series_count as f32;
678        let bar_gap = group_width * 0.1;
679
680        for (si, series) in self.series.iter().enumerate() {
681            for (i, &(_, y)) in series.points.iter().enumerate() {
682                let bar_height = ((y - y_min) / y_range) as f32 * plot.height;
683                let x = (si as f32)
684                    .mul_add(bar_width, (i as f32).mul_add(group_width, plot.x + bar_gap));
685                let rect = Rect::new(
686                    x,
687                    plot.y + plot.height - bar_height,
688                    bar_width - 2.0,
689                    bar_height,
690                );
691                canvas.fill_rect(rect, series.color);
692            }
693        }
694    }
695
696    /// Paint scatter chart.
697    fn paint_scatter(&self, canvas: &mut dyn Canvas, plot: &Rect, bounds: &(f64, f64, f64, f64)) {
698        for series in &self.series {
699            for &(x, y) in &series.points {
700                let pt = self.map_point(x, y, bounds, plot);
701                canvas.fill_circle(pt, series.point_size / 2.0, series.color);
702            }
703        }
704    }
705
706    /// Paint pie chart.
707    fn paint_pie(&self, canvas: &mut dyn Canvas, plot: &Rect) {
708        // Sum all Y values across series
709        let total: f64 = self
710            .series
711            .iter()
712            .flat_map(|s| s.points.iter().map(|(_, y)| *y))
713            .sum();
714
715        if total <= 0.0 {
716            return;
717        }
718
719        let cx = plot.x + plot.width / 2.0;
720        let cy = plot.y + plot.height / 2.0;
721        let radius = plot.width.min(plot.height) / 2.0 * 0.8;
722        let center = Point::new(cx, cy);
723
724        // Draw pie segments as arcs
725        let mut start_angle: f32 = -std::f32::consts::FRAC_PI_2; // Start from top
726        for series in &self.series {
727            for &(_, y) in &series.points {
728                let fraction = (y / total) as f32;
729                let sweep = fraction * std::f32::consts::TAU;
730                let end_angle = start_angle + sweep;
731
732                canvas.fill_arc(center, radius, start_angle, end_angle, series.color);
733
734                start_angle = end_angle;
735            }
736        }
737    }
738
739    /// Paint heatmap chart - displays matrix data with color encoding.
740    fn paint_heatmap(&self, canvas: &mut dyn Canvas, plot: &Rect, bounds: &(f64, f64, f64, f64)) {
741        let (_, _, y_min, y_max) = *bounds;
742        let y_range = (y_max - y_min).max(1e-10);
743
744        // For heatmap, we treat each series as a row and each point as a cell
745        let row_count = self.series.len();
746        if row_count == 0 {
747            return;
748        }
749
750        let col_count = self
751            .series
752            .iter()
753            .map(|s| s.points.len())
754            .max()
755            .unwrap_or(1);
756
757        let cell_width = plot.width / col_count as f32;
758        let cell_height = plot.height / row_count as f32;
759
760        for (row, series) in self.series.iter().enumerate() {
761            for (col, &(_, value)) in series.points.iter().enumerate() {
762                // Map value to color intensity (blue to red)
763                let t = ((value - y_min) / y_range) as f32;
764                let color = Color::new(t, 0.2, 1.0 - t, 1.0);
765
766                let rect = Rect::new(
767                    (col as f32).mul_add(cell_width, plot.x),
768                    (row as f32).mul_add(cell_height, plot.y),
769                    cell_width - 1.0,
770                    cell_height - 1.0,
771                );
772                canvas.fill_rect(rect, color);
773            }
774        }
775    }
776
777    /// Paint box plot - displays statistical distributions.
778    fn paint_boxplot(&self, canvas: &mut dyn Canvas, plot: &Rect, bounds: &(f64, f64, f64, f64)) {
779        let (_, _, y_min, y_max) = *bounds;
780        let y_range = (y_max - y_min).max(1e-10);
781
782        let series_count = self.series.len();
783        if series_count == 0 {
784            return;
785        }
786
787        let box_width = (plot.width / series_count as f32) * 0.6;
788        let gap = (plot.width / series_count as f32) * 0.2;
789
790        for (i, series) in self.series.iter().enumerate() {
791            if series.points.len() < 5 {
792                continue; // Need at least 5 points for box plot (min, q1, median, q3, max)
793            }
794
795            // Sort points by y value for quartile calculation
796            let mut values: Vec<f64> = series.points.iter().map(|(_, y)| *y).collect();
797            values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
798
799            let min_val = values[0];
800            let q1 = values[values.len() / 4];
801            let median = values[values.len() / 2];
802            let q3 = values[3 * values.len() / 4];
803            let max_val = values[values.len() - 1];
804
805            let x_center = (i as f32).mul_add(plot.width / series_count as f32, plot.x + gap);
806
807            // Map y values to screen coordinates
808            let map_y = |v: f64| -> f32 {
809                let t = (v - y_min) / y_range;
810                (1.0 - t as f32).mul_add(plot.height, plot.y)
811            };
812
813            let y_min_px = map_y(min_val);
814            let y_q1 = map_y(q1);
815            let y_median = map_y(median);
816            let y_q3 = map_y(q3);
817            let y_max_px = map_y(max_val);
818
819            // Draw whiskers (vertical lines from min to q1 and q3 to max)
820            canvas.draw_line(
821                Point::new(x_center + box_width / 2.0, y_min_px),
822                Point::new(x_center + box_width / 2.0, y_q1),
823                series.color,
824                1.0,
825            );
826            canvas.draw_line(
827                Point::new(x_center + box_width / 2.0, y_q3),
828                Point::new(x_center + box_width / 2.0, y_max_px),
829                series.color,
830                1.0,
831            );
832
833            // Draw box (from q1 to q3)
834            let box_rect = Rect::new(x_center, y_q3, box_width, y_q1 - y_q3);
835            canvas.fill_rect(box_rect, series.color);
836            canvas.stroke_rect(box_rect, Color::new(0.0, 0.0, 0.0, 1.0), 1.0);
837
838            // Draw median line
839            canvas.draw_line(
840                Point::new(x_center, y_median),
841                Point::new(x_center + box_width, y_median),
842                Color::new(0.0, 0.0, 0.0, 1.0),
843                2.0,
844            );
845
846            // Draw caps (horizontal lines at min and max)
847            let cap_width = box_width * 0.3;
848            canvas.draw_line(
849                Point::new(x_center + box_width / 2.0 - cap_width / 2.0, y_min_px),
850                Point::new(x_center + box_width / 2.0 + cap_width / 2.0, y_min_px),
851                series.color,
852                1.0,
853            );
854            canvas.draw_line(
855                Point::new(x_center + box_width / 2.0 - cap_width / 2.0, y_max_px),
856                Point::new(x_center + box_width / 2.0 + cap_width / 2.0, y_max_px),
857                series.color,
858                1.0,
859            );
860        }
861    }
862
863    /// Paint legend.
864    fn paint_legend(&self, canvas: &mut dyn Canvas) {
865        if self.legend == LegendPosition::None || self.series.is_empty() {
866            return;
867        }
868
869        let entry_height = 20.0;
870        let legend_width = 100.0;
871        let legend_height = (self.series.len() as f32).mul_add(entry_height, 10.0);
872
873        let (lx, ly) = match self.legend {
874            LegendPosition::TopRight => (
875                self.bounds.x + self.bounds.width - legend_width - 10.0,
876                self.bounds.y + self.padding + 10.0,
877            ),
878            LegendPosition::TopLeft => (
879                self.bounds.x + self.padding + 10.0,
880                self.bounds.y + self.padding + 10.0,
881            ),
882            LegendPosition::BottomRight => (
883                self.bounds.x + self.bounds.width - legend_width - 10.0,
884                self.bounds.y + self.bounds.height - legend_height - 10.0,
885            ),
886            LegendPosition::BottomLeft => (
887                self.bounds.x + self.padding + 10.0,
888                self.bounds.y + self.bounds.height - legend_height - 10.0,
889            ),
890            LegendPosition::None => return,
891        };
892
893        // Legend background
894        canvas.fill_rect(
895            Rect::new(lx, ly, legend_width, legend_height),
896            Color::new(1.0, 1.0, 1.0, 0.9),
897        );
898        canvas.stroke_rect(
899            Rect::new(lx, ly, legend_width, legend_height),
900            Color::new(0.8, 0.8, 0.8, 1.0),
901            1.0,
902        );
903
904        // Legend entries
905        let text_style = TextStyle {
906            size: 12.0,
907            color: Color::new(0.2, 0.2, 0.2, 1.0),
908            ..TextStyle::default()
909        };
910
911        for (i, series) in self.series.iter().enumerate() {
912            let ey = (i as f32).mul_add(entry_height, ly + 5.0);
913            // Color box
914            canvas.fill_rect(Rect::new(lx + 5.0, ey + 4.0, 12.0, 12.0), series.color);
915            // Label
916            canvas.draw_text(&series.name, Point::new(lx + 22.0, ey + 14.0), &text_style);
917        }
918    }
919}
920
921impl Widget for Chart {
922    fn type_id(&self) -> TypeId {
923        TypeId::of::<Self>()
924    }
925
926    fn measure(&self, constraints: Constraints) -> Size {
927        let width = self.width.unwrap_or(400.0);
928        let height = self.height.unwrap_or(300.0);
929        constraints.constrain(Size::new(width, height))
930    }
931
932    fn layout(&mut self, bounds: Rect) -> LayoutResult {
933        self.bounds = bounds;
934        LayoutResult {
935            size: bounds.size(),
936        }
937    }
938
939    fn paint(&self, canvas: &mut dyn Canvas) {
940        // Background
941        canvas.fill_rect(self.bounds, self.background);
942
943        // Title
944        if let Some(ref title) = self.title {
945            let text_style = TextStyle {
946                size: 16.0,
947                color: Color::new(0.1, 0.1, 0.1, 1.0),
948                ..TextStyle::default()
949            };
950            canvas.draw_text(
951                title,
952                Point::new(
953                    (title.len() as f32).mul_add(-4.0, self.bounds.x + self.bounds.width / 2.0),
954                    self.bounds.y + 25.0,
955                ),
956                &text_style,
957            );
958        }
959
960        let plot = self.plot_area();
961
962        // Get data bounds
963        let Some(bounds) = self.data_bounds() else {
964            return;
965        };
966
967        // Draw grid
968        self.paint_grid(canvas, &plot, &bounds);
969
970        // Draw chart based on type
971        match self.kind {
972            ChartType::Line | ChartType::Area => self.paint_line(canvas, &plot, &bounds),
973            ChartType::Bar | ChartType::Histogram => self.paint_bar(canvas, &plot, &bounds),
974            ChartType::Scatter => self.paint_scatter(canvas, &plot, &bounds),
975            ChartType::Pie => self.paint_pie(canvas, &plot),
976            ChartType::Heatmap => self.paint_heatmap(canvas, &plot, &bounds),
977            ChartType::BoxPlot => self.paint_boxplot(canvas, &plot, &bounds),
978        }
979
980        // Draw legend
981        self.paint_legend(canvas);
982    }
983
984    fn event(&mut self, _event: &presentar_core::Event) -> Option<Box<dyn Any + Send>> {
985        // Charts are currently view-only
986        None
987    }
988
989    fn children(&self) -> &[Box<dyn Widget>] {
990        &[]
991    }
992
993    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
994        &mut []
995    }
996
997    fn is_interactive(&self) -> bool {
998        false
999    }
1000
1001    fn is_focusable(&self) -> bool {
1002        false
1003    }
1004
1005    fn accessible_name(&self) -> Option<&str> {
1006        self.accessible_name_value
1007            .as_deref()
1008            .or(self.title.as_deref())
1009    }
1010
1011    fn accessible_role(&self) -> AccessibleRole {
1012        AccessibleRole::Image // Charts are treated as images for accessibility
1013    }
1014
1015    fn test_id(&self) -> Option<&str> {
1016        self.test_id_value.as_deref()
1017    }
1018}
1019
1020// PROBAR-SPEC-009: Brick Architecture - Tests define interface
1021impl Brick for Chart {
1022    fn brick_name(&self) -> &'static str {
1023        "Chart"
1024    }
1025
1026    fn assertions(&self) -> &[BrickAssertion] {
1027        &[BrickAssertion::MaxLatencyMs(16)]
1028    }
1029
1030    fn budget(&self) -> BrickBudget {
1031        BrickBudget::uniform(16)
1032    }
1033
1034    fn verify(&self) -> BrickVerification {
1035        BrickVerification {
1036            passed: self.assertions().to_vec(),
1037            failed: vec![],
1038            verification_time: Duration::from_micros(10),
1039        }
1040    }
1041
1042    fn to_html(&self) -> String {
1043        let test_id = self.test_id_value.as_deref().unwrap_or("chart");
1044        let title = self.title.as_deref().unwrap_or("Chart");
1045        format!(
1046            r#"<div class="brick-chart" data-testid="{test_id}" role="img" aria-label="{title}">{title}</div>"#
1047        )
1048    }
1049
1050    fn to_css(&self) -> String {
1051        ".brick-chart { display: block; }".into()
1052    }
1053
1054    fn test_id(&self) -> Option<&str> {
1055        self.test_id_value.as_deref()
1056    }
1057}
1058
1059#[cfg(test)]
1060#[allow(clippy::unwrap_used, clippy::disallowed_methods)]
1061#[path = "chart_tests.rs"]
1062mod tests;