ChartBuilder in conjunction with if .. else construct

The following code works out fine when either
". build_cartesian_2d(0..sequence.len() as i64, 0..max_value)?; "or
". build_cartesian_2d(0..sequence.len() as i64, (0..max_value).log_scale())? ";
is used

but when I enable the if -- else construct in order to have the y scale linear or logarithmic depending on max_value I run into errors at:

chart
.configure_mesh()
.x_labels(20)
.y_labels(10)
.x_label_style(("sans-serif", 16).into_font())
.y_label_style(("sans-serif", 16).into_font())
.draw()?;

chart
.draw_series(LineSeries::new(sequence.iter().enumerate().map(|(i, &val)| (i as i64, val)),&BLUE,))?;

stating that the method could not be found in this scope.
How to proceed here?

Code:

use std::io;
use std::process::Command;
use plotters::prelude::*;
use plotters::coord::combinators::IntoLogRange;


fn collatz(mut n: i64) -> Vec<i64> {
    let mut sequence = vec![n];

    while n != 1 {
        if n % 2 == 0 {
            n = n / 2;
        } else {
            n = 3 * n + 1;
        }
        sequence.push(n);
    }

    sequence
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    println!("Enter an integer for the Collatz sequence:");

    let mut input_value = String::new();
    io::stdin().read_line(&mut input_value)?;

    let input_value = match input_value.trim().parse::<i64>() {
        Ok(value) if value > 0 => value,
        _ => {
            println!("Invalid input. Please provide a valid positive integer.");
            return Ok(());
        }
    };

    let sequence = collatz(input_value);

    // Create a plot
    let root = BitMapBackend::new("collatz_sequence.png", (800, 600)).into_drawing_area();
    root.fill(&WHITE)?;

    let max_value = *sequence.iter().max().unwrap() as i64;
    let title = format!("Collatz Sequence of {}", input_value);
    let mut chart = ChartBuilder::on(&root)//;

    //if max_value <=100000 {
    //chart 
        .caption(title, ("sans-serif", 24))
        .x_label_area_size(50)
        .y_label_area_size(80)
        .build_cartesian_2d(0..sequence.len() as i64, 0..max_value)?;
        
	/*} 
	else {    
    
	chart
        .build_cartesian_2d(0..sequence.len() as i64, (0..max_value).log_scale())?;   
	}*/
	     
    // Plot the Collatz sequence
   
   chart
		.configure_mesh()
        .x_labels(20)
        .y_labels(10)
        .x_label_style(("sans-serif", 16).into_font()) 
		.y_label_style(("sans-serif", 16).into_font())
        .draw()?;
        
   chart
		.draw_series(LineSeries::new(sequence.iter().enumerate().map(|(i, &val)| (i as i64, val)),&BLUE,))?;

    // Finish the plot to ensure it's saved
    root.present()?;

    // Open the generated image using the 'open' command
    
    Command::new("open")
        .arg("collatz_sequence.png")
        .output()
        .expect("Failed to open image");

    Ok(())
}

You need to call draw_series on the thing that build_cartesian_2d returns in the Ok variant.

receiver.method(argument, […arguments,]) is, syntactically, indivisible. That is, you cannot interpose other expressions or statements in it, other than in place of the receiver or an argument. In particular, you cannot replace the .method(…) part with an expression.

To make something like this work, you need to package up a whole expression in a way that fits into your if statement, including the receiver. There are a couple of ways to do this. The simplest is to separate the parts that vary under your condition from the parts that don't, via a variable:

    let /* mut if needed */ chart = ChartBuilder::on(&root)
        .caption(title, ("sans-serif", 24))
        .x_label_area_size(50)
        .y_label_area_size(80);

    let /* … */ chart = if max_value <= 100_000 {
        chart
            .build_cartesian_2d(0..sequence.len() as i64, 0..max_value)?
    } else {
        chart
            .build_cartesian_2d(0..sequence.len() as i64, (0..max_value).log_scale())?
    }

This is resulting in following error:

cargo test (in directory: /Users/ralf/projects/Rust/testing/src)
Compiling testing v0.1.0 (/Users/ralf/projects/Rust/testing)
error[E0308]: if and else have incompatible types
--> src/main.rs:56:9
|
52 | let /* … */ chart = if max_value <= 100_000 {
| -
53 | | / chart
54 | | | .build_cartesian_2d(0..sequence.len() as i64, 0..max_value)?
| | |
- expected because of this
55 | | } else {
56 | | / chart
57 | | | .build_cartesian_2d(0..sequence.len() as i64, (0..max_value).log_scale())?
| | |
_________________________________________^ expected RangedCoordi64, found LogCoord<i64>
58 | | };
| |_______- if and else have incompatible types
|
= note: expected struct ChartContext<'_, _, Cartesian2d<_, RangedCoordi64>>
found struct ChartContext<'_, _, Cartesian2d<_, LogCoord<i64>>>
For more information about this error, try rustc --explain E0308.
error: could not compile testing (bin "testing" test) due to previous error
Compilation failed.

Ah, progress. The error message changed.

At this point I would suggest contacting the author of the library. While there are a number of ways to address the way the "kind" of coordinate system is encoded in the type of the chart context, there's likely a recommended way to do it, and if there isn't, that's also useful feedback for the author.

Note: This is a very long post. You can skip ahead to "The right way?" section if you just want the actual answer.

The problem is that, in each branch, you have different types that are created by build_cartesian_2d() with different argument types. These different return types cannot be bound to a single variable without some kind of indirection. Either dynamic dispatch or static dispatch through a function call.

For dynamic dispatch, you would end up with:

let chart: Box<dyn _> = if ...

For some trait that both branches implement. In this case, calling Box::new() is the generic function call. But we can also try static dispatch, which we'll explore in the next section.

First try

What you can do for this situation, though I don't know if I would recommend it, is moving the chart drawing code to a function that accepts some generic parameters. That way, you can have different types in each branch and pass them to a single function to do the actual drawing.

One way I was able to accomplish this with plotters gets really messy. But here it is, to show you how it could apply to other similar situations:

fn draw_chart<'a, C, F>(
    mut chart: ChartContext<'_, BitMapBackend<'a>, Cartesian2d<RangedCoordi64, C>>,
    sequence: &[i64],
) -> Result<(), Box<dyn std::error::Error>>
where
    C: Ranged<FormatOption = DefaultFormatting> + ValueFormatter<F>,
    <C as Ranged>::ValueType: std::fmt::Debug,
    for<'b> &'b DynElement<'static, BitMapBackend<'a>, (i64, i64)>:
        PointCollection<'b, (i64, <C as Ranged>::ValueType)>,
{
    chart
        .configure_mesh()
        .x_labels(20)
        .y_labels(10)
        .x_label_style(("sans-serif", 16).into_font())
        .y_label_style(("sans-serif", 16).into_font())
        .draw()?;

    chart.draw_series(LineSeries::new(
        sequence.iter().enumerate().map(|(i, &val)| (i as i64, val)),
        &BLUE,
    ))?;

    Ok(())
}

This is the function where I moved all of the drawing code. Yeah, it's hard to read. It wasn't easy to write, either. Some of this could possibly be simplified with any type aliases that plotters might export to aid in constructions like this. I don't know, I haven't really used the crate!

The call site is much simpler:

    let mut chart = ChartBuilder::on(&root);
    chart
        .caption(title, ("sans-serif", 24))
        .x_label_area_size(50)
        .y_label_area_size(80);

    // Plot the Collatz sequence
    if max_value <= 100_000 {
        draw_chart(
            chart.build_cartesian_2d(0..sequence.len() as i64, 0..max_value)?,
            &sequence,
        )?;
    } else {
        draw_chart(
            chart.build_cartesian_2d(0..sequence.len() as i64, (0..max_value).log_scale())?,
            &sequence,
        )?;
    }

A better way?

It doesn't seem like reusing drawing code on different kinds of charts is supported by the API. An alternative that hides the type juggling is using a macro:

macro_rules! draw_chart {
    ($chart:expr, $sequence:expr $(,)?) => {
        $chart
            .configure_mesh()
            .x_labels(20)
            .y_labels(10)
            .x_label_style(("sans-serif", 16).into_font())
            .y_label_style(("sans-serif", 16).into_font())
            .draw()?;

        $chart.draw_series(LineSeries::new(
            $sequence.iter().enumerate().map(|(i, &val)| (i as i64, val)),
            &BLUE,
        ))?;
    };
}

The call sites are mostly the same as shown above, just add the ! for macro invocation and remove the trailing ?.

The right way?

plotters is super trait heavy (as you can tell from the above experiments). It's a curse, but it's also a bit of a blessing [1]. One of the good things about this API is that you can implement your own coordinate type and do the customization in a single type, rather than deferring to different types that plotters offers.

Admittedly, this was not straightforward to discover. Nor was it obvious how to implement. But since I already did the hard work:

enum CustomY {
    Log(LogCoord<i64>),
    Linear(RangedCoordi64),
}

impl CustomY {
    fn new(max_value: i64) -> Self {
        if max_value >= 100_000 {
            Self::Log((0..max_value).log_scale().into())
        } else {
            Self::Linear((0..max_value).into())
        }
    }
}

impl Ranged for CustomY {
    type ValueType = i64;
    type FormatOption = DefaultFormatting;

    fn map(&self, value: &Self::ValueType, limit: (i32, i32)) -> i32 {
        match self {
            Self::Log(ranged) => ranged.map(value, limit),
            Self::Linear(ranged) => ranged.map(value, limit),
        }
    }

    fn range(&self) -> std::ops::Range<Self::ValueType> {
        match self {
            Self::Log(ranged) => ranged.range(),
            Self::Linear(ranged) => ranged.range(),
        }
    }

    fn key_points<Hint: KeyPointHint>(&self, hint: Hint) -> Vec<Self::ValueType> {
        match self {
            Self::Log(ranged) => ranged.key_points(hint),
            Self::Linear(ranged) => ranged.key_points(hint),
        }
    }
}

This gives us a custom coordinate type that is either linear or logarithmic depending on the maximum value it is constructed with. Basically, exactly what you want. Here's how to use it:

let mut chart = ChartBuilder::on(&root)
    .caption(title, ("sans-serif", 24))
    .x_label_area_size(50)
    .y_label_area_size(80)
    .build_cartesian_2d(0..sequence.len() as i64, CustomY::new(max_value))?;

// Plot the Collatz sequence
chart
    .configure_mesh()
    .x_labels(20)
    .y_labels(10)
    .x_label_style(("sans-serif", 16).into_font())
    .y_label_style(("sans-serif", 16).into_font())
    .draw()?;

chart.draw_series(LineSeries::new(
    sequence.iter().enumerate().map(|(i, &val)| (i as i64, val)),
    &BLUE,
))?;

Nothing really surprising here, the only thing that changes from the code in OP is that our new CustomY type is passed to build_cartesian_2d(). That's it! Nothing more to see here...

Teach me to fish

What, you're still here? Ok. Let's walk through my thought process to find how I arrived at this solution.

  1. I've been using Rust for a long time. Hmm, 8 years or so. Safe to say I've run into this exact situation on a few occasions. My go-to work around is the first attempt you see here, with the generic function. It works, but man, it gets ugly real fast!

    The real problem with it is that the way I struggled through type errors to derive the trait bounds (just making the compiler tell me what it wanted and trying stuff until it worked) only provides a function that will work with certain concrete types. For instance, if you ever changed the values from i64 to something else, well, now you need to make those types generic, too. And so on.

    I did try to make it "fully generic" by using the actual trait bounds mentioned in the documentation. And it worked, but it was even more gnarly, and I thought it distracted too much from the actual goal. So, I stopped there and moved on...

  2. ... to the macro version! This is of course far easier to write and does practically the same thing as the generic function without all of the type shenanigans. It also works with any coordinate type, and you don't have to add new trait bounds when the body changes, so that's a bonus!

    It still has problems though. Maily, there's still a branch with code that is essentially duplicated. It isn't DRY!

  3. The way to actually DRY it up is something that I stumbled upon while looking through the examples: Specifically customized_coord.rs. This almost exactly matches the use case. You want to switch the Y-coordinate scale at runtime. And a custom coordinate can obviously do that!

    I had a few false starts when I started working on this. The first stumbling block is that the type returned by IntoLogRange::log_scale() is unnamable! It's called LogRangeExt, but it's a private type. The only thing we can do with it is call methods on it.

    Luckily, there is a From impl we can use, but which is not documented (because of that silly private type): https://github.com/plotters-rs/plotters/blob/7c1f8ec41ae9e70b91c3427bd19c929ac45fb31d/plotters/src/coord/ranged1d/combinators/logarithmic.rs#L112C1-L112C58 And so, we can store a LogCoord and everything is happy! Delegate the Ranged impls to either of the two Ranged implementers that CustomY can own.

That covers everything. Except for one thing I forgot to mention... Even though this works, I'm unfamiliar with plotters, and I don't know if the resulting plot is actually what you expect. I'm sure you can figure that out!

I also don't really know what else this crate is capable of. But I'm happy that, in this case, there is a fairly solid solution. Even if it's really hard to find! Good luck!


  1. But mostly it's a curse. ↩︎

3 Likes

Thanks a lot for your comprehensive work.
I have to take a look at it in peace

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.