use super::funejson::Value; use super::charts_axis_axis_from_ticks::{axis_to_value, line_to_value}; use super::charts_axis_grid_lines::grid_lines; use super::charts_axis_number_axis::number_axis; use super::charts_axis_time_axis::time_axis; use super::charts_extent::extent; use super::charts_format_format_tick::format_tick; use super::charts_layout_legend_rows::{legend_item_to_value, legend_rows}; use super::charts_layout_plot_area::{plot_area, plot_area_to_value}; use super::charts_layout_types::{LegendItem, Margins}; use super::charts_palette::palette; use super::charts_scale_linear_scale::linear_scale; use super::charts_scale_nice_domain::nice_domain; use super::charts_scale_time_scale::time_scale; use super::charts_shape_line_path::line_path; use super::charts_shape_monotone_curve::monotone_curve; use super::charts_shape_step_path::step_path; use super::charts_shape_types::Point; use super::charts_ticks_nice_ticks::nice_ticks; use super::charts_ticks_tick_step::tick_step; use super::math_round_float::round_float; // Fixed layout constants; the README explains each. const CHAR_WIDTH: f64 = 7.0; const SWATCH: f64 = 10.0; const LEGEND_GAP: f64 = 16.0; const LEGEND_ROW: f64 = 18.0; const EDGE: f64 = 8.0; const TICK_SIZE: f64 = 6.0; const LABEL_PADDING: f64 = 3.0; const TOP_WITHOUT_LEGEND: f64 = 12.0; const RIGHT: f64 = 20.0; const BOTTOM: f64 = 30.0; /// A whole line chart as geometry, by the fixed rules in the README. /// /// # Panics /// Panics on no series, both or neither of x and dates, a series of the wrong /// length, no values, fewer than two different x values, or a chart too small /// for its margins. pub fn line_chart(spec: &LineChartSpec) -> LineChart { let (width, height, series) = (spec.width, spec.height, &spec.series); if series.is_empty() { panic!("a line chart needs at least one series"); } if spec.x.is_none() == spec.dates.is_none() { panic!("give exactly one of x and dates"); } let count = match (&spec.x, &spec.dates) { (Some(x), _) => x.len(), (_, Some(d)) => d.len(), _ => 0, }; for s in series { if s.values.len() != count { panic!("series \"{}\" has {} values but there are {} x values", s.name, s.values.len(), count); } } let mut legend: Vec = Vec::new(); let mut top = TOP_WITHOUT_LEGEND; if series.len() > 1 { let names: Vec = series.iter().map(|s| s.name.clone()).collect(); let rows = legend_rows(&names, width - 2.0 * EDGE, CHAR_WIDTH, SWATCH, LEGEND_GAP, LEGEND_ROW); legend = rows .iter() .map(|it| LegendItem { label: it.label.clone(), row: it.row, x: round_float(it.x + EDGE, 2), y: round_float(it.y + EDGE, 2), width: it.width, text_x: round_float(it.text_x + EDGE, 2), }) .collect(); top = EDGE + (rows[rows.len() - 1].row + 1) as f64 * LEGEND_ROW + EDGE; } let all: Vec> = series.iter().flat_map(|s| s.values.iter().cloned()).collect(); let found = match extent(&all) { None => panic!("a line chart needs at least one value to plot"), Some(r) => r, }; let (mut lo, mut hi) = (found[0], found[1]); if spec.y_zero { lo = lo.min(0.0); hi = hi.max(0.0); } if lo == hi { lo -= 1.0; hi += 1.0; } let y_count = (((height - top - BOTTOM) / 50.0).floor() as i64).max(2); let y_domain = nice_domain(&[lo, hi], y_count).domain; let y_step = tick_step(y_domain[0], y_domain[1], y_count); let widest = nice_ticks(y_domain[0], y_domain[1], y_count) .iter() .map(|v| format_tick(*v, y_step).chars().count()) .max() .unwrap_or(0); let left = TICK_SIZE + LABEL_PADDING + widest as f64 * CHAR_WIDTH + EDGE; let plot = plot_area(width, height, &Margins { top, right: RIGHT, bottom: BOTTOM, left }); let bottom_y = plot.y + plot.height; let y_range = vec![bottom_y, plot.y]; let y_axis = number_axis(&y_domain, &y_range, y_count, "left", plot.x, TICK_SIZE); let x_range = vec![plot.x, plot.x + plot.width]; let x_count = ((plot.width / 80.0).floor() as i64).max(2); let (x_axis, xs) = match (&spec.x, &spec.dates) { (Some(x), _) => { let opt: Vec> = x.iter().map(|v| Some(*v)).collect(); let d = match extent(&opt) { Some(d) if d[0] != d[1] => d, _ => panic!("a line chart needs at least two different x values"), }; let xs: Vec = x.iter().map(|v| linear_scale(&d, &x_range, *v, false)).collect(); (number_axis(&d, &x_range, x_count, "bottom", bottom_y, TICK_SIZE), xs) } (_, Some(dates)) => { let first = dates.iter().min().cloned(); let last = dates.iter().max().cloned(); let (first, last) = match (first, last) { (Some(f), Some(l)) if f != l => (f, l), _ => panic!("a line chart needs at least two different x values"), }; let domain = vec![first, last]; let xs: Vec = dates.iter().map(|d| time_scale(&domain, &x_range, d, false)).collect(); (time_axis(&domain, &x_range, x_count, "bottom", bottom_y, TICK_SIZE), xs) } _ => unreachable!(), }; let colors = palette("okabe-ito", series.len() as i64, true); let lines: Vec = series .iter() .enumerate() .map(|(i, s)| { let points: Vec = s .values .iter() .enumerate() .map(|(j, v)| Point { x: xs[j], y: v.map(|v| linear_scale(&y_domain, &y_range, v, false)) }) .collect(); let path = match spec.curve.as_str() { "monotone" => monotone_curve(&points), "step" => step_path(&points, "middle"), _ => line_path(&points), }; SeriesPath { name: s.name.clone(), color: colors[i].clone(), path } }) .collect(); LineChart { width: round_float(width, 2), height: round_float(height, 2), grid_lines: grid_lines(&y_axis, plot.width), plot, x_axis, y_axis, lines, legend, } } fn opt_floats(v: &Value) -> Vec> { v.as_arr().iter().map(|x| if x.is_null() { None } else { Some(x.as_f64()) }).collect() } pub fn line_chart_spec_from_value(v: &Value) -> LineChartSpec { LineChartSpec { width: v.get("width").as_f64(), height: v.get("height").as_f64(), x: if v.get("x").is_null() { None } else { Some(v.get("x").as_arr().iter().map(|x| x.as_f64()).collect()) }, dates: if v.get("dates").is_null() { None } else { Some(v.get("dates").as_arr().iter().map(|x| x.as_str().to_string()).collect()) }, series: v .get("series") .as_arr() .iter() .map(|s| LineSeries { name: s.get("name").as_str().to_string(), values: opt_floats(s.get("values")) }) .collect(), curve: v.get("curve").as_str().to_string(), y_zero: v.get("yZero").as_bool(), } } pub fn line_chart_to_value(c: &LineChart) -> Value { Value::obj(vec![ ("width", Value::Float(c.width)), ("height", Value::Float(c.height)), ("plot", plot_area_to_value(&c.plot)), ("xAxis", axis_to_value(&c.x_axis)), ("yAxis", axis_to_value(&c.y_axis)), ("gridLines", Value::Arr(c.grid_lines.iter().map(line_to_value).collect())), ( "lines", Value::Arr( c.lines .iter() .map(|l| { Value::obj(vec![ ("name", Value::str(&l.name)), ("color", Value::str(&l.color)), ("path", Value::str(&l.path)), ]) }) .collect(), ), ), ("legend", Value::Arr(c.legend.iter().map(legend_item_to_value).collect())), ]) } pub fn fune_vector(args: &[Value]) -> Value { line_chart_to_value(&line_chart(&line_chart_spec_from_value(&args[0]))) }