Functional Weave
Code in Python

charts.line-chart@1.0.0

impl/rust.rs

8,451 bytes · the Rust implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
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;  ← from charts.extent ^1.0.0 · built alongside by fune
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;  ← from charts.palette ^1.0.0 · built alongside by fune
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;  ← from math.round-float ^1.0.0 · built alongside by fune

// 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<LegendItem> = Vec::new();
    let mut top = TOP_WITHOUT_LEGEND;
    if series.len() > 1 {
        let names: Vec<String> = 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<Option<f64>> = 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<Option<f64>> = 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<f64> = 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<f64> = 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<SeriesPath> = series
        .iter()
        .enumerate()
        .map(|(i, s)| {
            let points: Vec<Point> = 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<Option<f64>> {
    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])))
}