1use crate::error::ChartError;
4use crate::{
5 DEFAULT_COLOR, DEFAULT_HEIGHT, DEFAULT_PADDING_FRACTION, DEFAULT_TITLE_FONT_SIZE,
6 DEFAULT_WIDTH, ScaleType, TITLE_AREA_HEIGHT, extent_padded, validate_data_array,
7 validate_data_length, validate_dimensions, validate_positive,
8};
9use d3rs::axis::{AxisConfig, DefaultAxisTheme, render_axis};
10use d3rs::color::D3Color;
11use d3rs::grid::{GridConfig, render_grid};
12use d3rs::scale::{LinearScale, LogScale};
13use d3rs::shape::{BarConfig, BarDatum, render_bars};
14use d3rs::text::{VectorFontConfig, render_vector_text};
15use gpui::prelude::*;
16use gpui::*;
17
18#[derive(Debug, Clone)]
20pub struct BarChart {
21 categories: Vec<String>,
22 values: Vec<f64>,
23 title: Option<String>,
24 color: u32,
25 opacity: f32,
26 bar_gap: f32,
27 border_radius: f32,
28 width: f32,
29 height: f32,
30 y_scale_type: ScaleType,
31}
32
33impl BarChart {
34 pub fn title(mut self, title: impl Into<String>) -> Self {
36 self.title = Some(title.into());
37 self
38 }
39
40 pub fn color(mut self, hex: u32) -> Self {
50 self.color = hex;
51 self
52 }
53
54 pub fn opacity(mut self, opacity: f32) -> Self {
56 self.opacity = opacity.clamp(0.0, 1.0);
57 self
58 }
59
60 pub fn bar_gap(mut self, gap: f32) -> Self {
62 self.bar_gap = gap;
63 self
64 }
65
66 pub fn border_radius(mut self, radius: f32) -> Self {
68 self.border_radius = radius;
69 self
70 }
71
72 pub fn size(mut self, width: f32, height: f32) -> Self {
74 self.width = width;
75 self.height = height;
76 self
77 }
78
79 pub fn y_scale(mut self, scale: ScaleType) -> Self {
89 self.y_scale_type = scale;
90 self
91 }
92
93 pub fn build(self) -> Result<impl IntoElement, ChartError> {
95 if self.categories.is_empty() {
97 return Err(ChartError::EmptyData {
98 field: "categories",
99 });
100 }
101 validate_data_array(&self.values, "values")?;
102 validate_data_length(
103 self.categories.len(),
104 self.values.len(),
105 "categories",
106 "values",
107 )?;
108 validate_dimensions(self.width, self.height)?;
109
110 if self.y_scale_type == ScaleType::Log {
112 validate_positive(&self.values, "values")?;
113 }
114
115 let margin_left = 50.0;
117 let margin_bottom = 30.0;
118 let margin_top = 10.0;
119 let margin_right = 20.0;
120
121 let title_height = if self.title.is_some() {
123 TITLE_AREA_HEIGHT
124 } else {
125 0.0
126 };
127
128 let plot_width = (self.width as f64 - margin_left - margin_right).max(0.0);
129 let plot_height =
130 (self.height as f64 - title_height as f64 - margin_top - margin_bottom).max(0.0);
131
132 let (mut y_min, mut y_max) = extent_padded(&self.values, DEFAULT_PADDING_FRACTION);
134
135 if self.y_scale_type == ScaleType::Linear {
138 y_min = y_min.min(0.0);
139 y_max = y_max.max(0.0);
140 }
141
142 let x_scale = LinearScale::new()
144 .domain(0.0, self.categories.len() as f64)
145 .range(0.0, plot_width);
146
147 let data: Vec<BarDatum> = self
149 .categories
150 .iter()
151 .zip(self.values.iter())
152 .map(|(cat, &val)| BarDatum::new(cat.clone(), val))
153 .collect();
154
155 let config = BarConfig::new()
157 .fill_color(D3Color::from_hex(self.color))
158 .opacity(self.opacity)
159 .bar_gap(self.bar_gap)
160 .border_radius(self.border_radius);
161
162 let theme = DefaultAxisTheme;
163
164 let chart_content: AnyElement = match self.y_scale_type {
166 ScaleType::Linear => {
167 let y_scale = LinearScale::new()
168 .domain(y_min, y_max)
169 .range(plot_height, 0.0);
170
171 div()
172 .flex()
173 .child(render_axis(
174 &y_scale,
175 &AxisConfig::left(),
176 plot_height as f32,
177 &theme,
178 ))
179 .child(
180 div()
181 .flex()
182 .flex_col()
183 .child(
184 div()
185 .w(px(plot_width as f32))
186 .h(px(plot_height as f32))
187 .relative()
188 .bg(rgb(0xf8f8f8))
189 .child(render_grid(
190 &x_scale,
191 &y_scale,
192 &GridConfig::default(),
193 plot_width as f32,
194 plot_height as f32,
195 &theme,
196 ))
197 .child(render_bars(
198 &x_scale,
199 &y_scale,
200 &data,
201 plot_width as f32,
202 plot_height as f32,
203 &config,
204 )),
205 )
206 .child(render_axis(
207 &x_scale,
208 &AxisConfig::bottom(),
209 plot_width as f32,
210 &theme,
211 )),
212 )
213 .into_any_element()
214 }
215 ScaleType::Log => {
216 let y_scale = LogScale::new()
217 .domain(y_min.max(1e-10), y_max)
218 .range(plot_height, 0.0);
219
220 div()
221 .flex()
222 .child(render_axis(
223 &y_scale,
224 &AxisConfig::left(),
225 plot_height as f32,
226 &theme,
227 ))
228 .child(
229 div()
230 .flex()
231 .flex_col()
232 .child(
233 div()
234 .w(px(plot_width as f32))
235 .h(px(plot_height as f32))
236 .relative()
237 .bg(rgb(0xf8f8f8))
238 .child(render_grid(
239 &x_scale,
240 &y_scale,
241 &GridConfig::default(),
242 plot_width as f32,
243 plot_height as f32,
244 &theme,
245 ))
246 .child(render_bars(
247 &x_scale,
248 &y_scale,
249 &data,
250 plot_width as f32,
251 plot_height as f32,
252 &config,
253 )),
254 )
255 .child(render_axis(
256 &x_scale,
257 &AxisConfig::bottom(),
258 plot_width as f32,
259 &theme,
260 )),
261 )
262 .into_any_element()
263 }
264 };
265
266 let mut container = div()
268 .w(px(self.width))
269 .h(px(self.height))
270 .relative()
271 .flex()
272 .flex_col();
273
274 if let Some(title) = &self.title {
276 let font_config =
277 VectorFontConfig::horizontal(DEFAULT_TITLE_FONT_SIZE, hsla(0.0, 0.0, 0.2, 1.0));
278 container = container.child(
279 div()
280 .w_full()
281 .h(px(title_height))
282 .flex()
283 .justify_center()
284 .items_center()
285 .child(render_vector_text(title, &font_config)),
286 );
287 }
288
289 container = container.child(div().relative().child(chart_content));
291
292 Ok(container)
293 }
294}
295
296pub fn bar<S: AsRef<str>>(categories: &[S], values: &[f64]) -> BarChart {
313 BarChart {
314 categories: categories.iter().map(|s| s.as_ref().to_string()).collect(),
315 values: values.to_vec(),
316 title: None,
317 color: DEFAULT_COLOR,
318 opacity: 0.8,
319 bar_gap: 2.0,
320 border_radius: 2.0,
321 width: DEFAULT_WIDTH,
322 height: DEFAULT_HEIGHT,
323 y_scale_type: ScaleType::Linear,
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 #[test]
332 fn test_bar_empty_categories() {
333 let empty_categories: Vec<&str> = vec![];
334 let result = bar(&empty_categories, &[1.0, 2.0, 3.0]).build();
335 assert!(matches!(
336 result,
337 Err(ChartError::EmptyData {
338 field: "categories"
339 })
340 ));
341 }
342
343 #[test]
344 fn test_bar_empty_values() {
345 let result = bar(&["A", "B", "C"], &[]).build();
346 assert!(matches!(
347 result,
348 Err(ChartError::EmptyData { field: "values" })
349 ));
350 }
351
352 #[test]
353 fn test_bar_data_length_mismatch() {
354 let result = bar(&["A", "B"], &[1.0, 2.0, 3.0]).build();
355 assert!(matches!(
356 result,
357 Err(ChartError::DataLengthMismatch {
358 x_field: "categories",
359 y_field: "values",
360 x_len: 2,
361 y_len: 3,
362 })
363 ));
364 }
365
366 #[test]
367 fn test_bar_invalid_value_nan() {
368 let result = bar(&["A", "B", "C"], &[1.0, f64::NAN, 3.0]).build();
369 assert!(matches!(
370 result,
371 Err(ChartError::InvalidData {
372 field: "values",
373 reason: "contains NaN or Infinity"
374 })
375 ));
376 }
377
378 #[test]
379 fn test_bar_successful_build() {
380 let categories = vec!["A", "B", "C", "D"];
381 let values = vec![10.0, 25.0, 15.0, 30.0];
382 let result = bar(&categories, &values)
383 .title("Test Bar Chart")
384 .color(0x2ca02c)
385 .build();
386 assert!(result.is_ok());
387 }
388
389 #[test]
390 fn test_bar_negative_values() {
391 let categories = vec!["A", "B", "C"];
392 let values = vec![-5.0, 10.0, -3.0];
393 let result = bar(&categories, &values).build();
394 assert!(result.is_ok());
395 }
396
397 #[test]
398 fn test_bar_builder_chain() {
399 let result = bar(&["X", "Y", "Z"], &[1.0, 2.0, 3.0])
400 .title("My Bar Chart")
401 .color(0xff0000)
402 .opacity(0.9)
403 .bar_gap(5.0)
404 .border_radius(4.0)
405 .size(800.0, 600.0)
406 .build();
407 assert!(result.is_ok());
408 }
409
410 #[test]
411 fn test_bar_log_y_scale() {
412 let categories = vec!["A", "B", "C", "D"];
413 let values = vec![10.0, 100.0, 1000.0, 10000.0];
414 let result = bar(&categories, &values).y_scale(ScaleType::Log).build();
415 assert!(result.is_ok());
416 }
417
418 #[test]
419 fn test_bar_log_y_scale_zero_value() {
420 let categories = vec!["A", "B", "C"];
421 let values = vec![0.0, 10.0, 100.0];
422 let result = bar(&categories, &values).y_scale(ScaleType::Log).build();
423 assert!(matches!(
424 result,
425 Err(ChartError::InvalidData {
426 field: "values",
427 reason: "contains non-positive values for log scale"
428 })
429 ));
430 }
431
432 #[test]
433 fn test_bar_log_y_scale_negative_value() {
434 let categories = vec!["A", "B", "C"];
435 let values = vec![-5.0, 10.0, 100.0];
436 let result = bar(&categories, &values).y_scale(ScaleType::Log).build();
437 assert!(matches!(
438 result,
439 Err(ChartError::InvalidData {
440 field: "values",
441 reason: "contains non-positive values for log scale"
442 })
443 ));
444 }
445
446 #[test]
447 fn test_bar_log_scale_with_title() {
448 let categories = vec!["Low", "Medium", "High"];
449 let values = vec![10.0, 100.0, 1000.0];
450 let result = bar(&categories, &values)
451 .title("Log Scale Bar Chart")
452 .y_scale(ScaleType::Log)
453 .color(0x2ca02c)
454 .build();
455 assert!(result.is_ok());
456 }
457}