ちょっとグラフを出力するプログラムが必要になりました。
しかし、Rust 修行中の身ですので、Excel や Python は使わないという縛りプレイでいきます。
過去記事を探り、以下の Rust の plotters クレートの記事をみつけました。
是非とも CSV ファイルに記録したデータでグラフを描画できるようにしたいと思い、少し拡張させていただきました。
その際に Depricated になっていた部分を更新し、また、X軸・Y軸の処理について見通しをよくしたかったのと、
今後は軸のバリエーションが増えることが予想されることから、軸に関する処理を抽象化する練習もしてみました。
二次元配列 TwoDimentionalArray および CSV ファイルから浮動小数点数データを読み出す from_csv_file 関数は以前紹介したものを流用しました。
お題
- CSV ファイルに記録された日付×数値のデータをグラフにしたい。
- CSVファイル (data.txt) のサンプル(元記事で使用されていたデータをCSVにしたものです)
date,value
2021-06-22,9960.0
2021-06-23,9782.0
2021-06-24,9824.0
2021-06-25,9849.0
2021-06-28,9842.0
2021-06-29,9740.0
2021-06-30,9710.0
2021-07-01,9690.0
2021-07-02,9820.0
2021-07-05,9772.0
2021-07-06,9794.0
2021-07-07,9734.0
2021-07-08,9675.0
2021-07-09,9650.0
2021-07-12,9815.0
2021-07-13,9865.0
2021-07-14,9869.0
2021-07-15,9832.0
2021-07-16,9866.0
2021-07-19,9740.0
2021-07-20,9612.0
2021-07-21,9725.0
2021-07-26,9829.0
2021-07-27,9871.0
2021-07-28,9804.0
2021-07-29,9856.0
2021-07-30,9805.0
2021-08-02,10030.0
実装例
以前紹介した TwoDimentinalArray 構造体と from_csv_file 関数は省略しています。
最初に定数の定義と main 関数です。
コマンドライン引数で CSVファイルのパスと出力先画像ファイルのパスを指定します。
chrono クレートの Date 構造体は Depricated になっていたので DateTime 構造体に変更しました。
// main.rs
// 出力するグラフ画像のサイズ
const IMAGE_WIDTH:u32 = 1024;
const IMAGE_HEIGHT:u32 = 768;
// 出力するグラフ画像のキャプション・フォント・サイズ
const CAPTION:&str = "graph";
const FONT_FACE:&str = "sans-serif";
const FONT_SIZE:i32 = 20;
// 上下左右全ての余白
const MARGIN:i32 = 10;
// x軸ラベル部分の余白
const X_LABEL_AREA_SIZE:i32 = 16;
// y軸ラベル部分の余白
const Y_LABEL_AREA_SIZE:i32 = 42;
// 点のサイズ
const CIRCLE_SIZE:i32 = 4;
use plotters::prelude::*;
use chrono::offset::{Local, TimeZone};
use chrono::DateTime;
fn main() {
let args:Vec<String> = std::env::args().collect();
if args.len() < 3 {
eprintln!("[Usage] {} data.csv out.png", args[0]);
return
}
let csv_file = &args[1];
let out_file = &args[2];
if let Err(e) = process(csv_file, out_file) {
eprintln!("[Error] {}", e);
}
}
process 関数は CSVファイルを読みだしてグラフを描画する全体の処理となります。
最初にプロット用データの準備を行います。
fn process(csv_file: &str, out_file:&str)
-> Result<(), Box<dyn std::error::Error>> {
// (1) プロット用データの準備
// CSV ファイルから読み出す
let data:TwoDimentionalArray<f32> = from_csv_file(csv_file)?;
// x軸
let x_strategy = DateTimeStrategy::new();
// x軸:日付の系列
let xs = x_strategy.series(&data.index())?;
// x軸の値の範囲
let x_range = x_strategy.range(&xs)?;
// y軸
let y_strategy = ValueStrategy::new();
// y軸:値の系列
let ys = y_strategy.series(&data.dat())?;
// y軸の値の範囲
let y_range = y_strategy.range(&ys)?;
CSVファイルから読みだしたデータから x 軸・y 軸それぞれの系列と値の範囲を取得しています。
次に描画先の情報を設定します。
このあたりは画像サイズの定数化と、出力先ファイルパスを変数化したこと以外はオリジナルのままです。
// (2) 描画先の情報を設定
// 描画先を指定。画像出力する場合はBitMapBackend
let root = BitMapBackend::new
(out_file, (IMAGE_WIDTH, IMAGE_HEIGHT)).into_drawing_area();
// 背景を白にする
root.fill(&WHITE)?;
次にグラフ全般の設定です。
ここも一部定数化したり、軸の値の範囲指定に Range インスタンスを指定した以外はオリジナルのままです。
// (3) グラフ全般の設定
let font = (FONT_FACE, FONT_SIZE);
let mut chart = ChartBuilder::on(&root)
.caption(CAPTION, font.into_font())
.margin(MARGIN)
.x_label_area_size(X_LABEL_AREA_SIZE)
.y_label_area_size(Y_LABEL_AREA_SIZE)
.build_cartesian_2d( // x軸とy軸の値の範囲を指定
x_range,
y_range
)?;
続いてグラフの描画です。
x軸・y軸、折れ線グラフ、点グラフを描画します。
ここも点に使う円のサイズを定数化した以外はオリジナルのままです。
// (4) グラフの描画
// x軸y軸、グリッド線などを描画
//chart.configure_mesh().draw()?;
chart.configure_mesh()
.x_label_formatter(&|x: &DateTime<Local>| x.format("%Y/%m/%d").to_string())
.draw()?;
// 折れ線グラフの描画
let line_series = LineSeries::new(
xs.iter()
.zip(ys.iter())
.map(|(x, y)| (*x, *y)),
&RED
);
chart.draw_series(line_series)?;
// 点グラフの描画
let point_series = xs.iter()
.zip(ys.iter())
.map(|(x, y)|
Circle::new(
(*x, *y),
CIRCLE_SIZE,
&RED, // 色を指定
// ↓円を塗りつぶしたければこちら
// ShapeStyle::from(&RED).filled(),
)
);
chart.draw_series(point_series)?;
Ok(())
}
続いて、軸の処理に関して AxisStrategy というトレイトを用意して抽象化してみました。
データ系列の作り方とデータの値の範囲の作り方は軸に使うデータの種類によってケースバイケースとなります。
今回は x 軸は日付の系列、y 軸は数値の系列ということですが、今後は日時や時刻、単位数、対数、…と軸のバリエーションが予想されるので、拡張しやすいよう今からこんな形で準備しておきます。
なんとなく "Strategy" 「戦略」というデザインパターン的なネーミングにしてみましたが、ここではあまり意味はありません。
// 軸の処理に関する抽象化
use core::ops::Range;
trait AxisStrategy <S,T> {
// データ系列の作成
fn series (&self, series:&Vec<S>) -> Result<Vec<T>, Box<dyn std::error::Error>>;
// データ範囲の作成
fn range (&self, series:&Vec<T>) -> Result<Range<T>, Box<dyn std::error::Error>>;
}
x 軸の日付の系列を軸とする戦略です。
series 関数で使ってる parse_time 関数で文字列から DateTime インスタンスに変換しています。
range では最初の日付と最後の日付からなる Range インスタンスを返しています。
// 日付の系列を軸とする戦略
struct DateTimeStrategy {}
impl DateTimeStrategy {
fn new() -> Self {
DateTimeStrategy{}
}
}
impl AxisStrategy <String, DateTime<Local>> for DateTimeStrategy {
fn series (&self, series:&Vec<String>) -> Result<Vec<DateTime<Local>>,Box<dyn std::error::Error>> {
Ok(
series.iter()
.map(|x| parse_time(x))
.collect()
)
}
fn range (&self, xs:&Vec<DateTime<Local>>) -> Result<Range<DateTime<Local>>, Box<dyn std::error::Error>> {
if let Some(xs_first) = xs.first() {
if let Some(xs_last) = xs.last() {
return Ok((*xs_first).clone()..(*xs_last).clone());
}
}
Err(Box::<dyn std::error::Error>::from("first or last value is absent!"))
}
}
y 軸の数値の系列の戦略です。
range 関数の方は最小値と最大値からなる Range インスタンスを返しています。
イテレータで fold を使う部分はオリジナルをそのまま流用させていただきました。
struct ValueStrategy {}
impl ValueStrategy {
fn new() -> Self {
ValueStrategy{}
}
}
impl AxisStrategy <f32, f32> for ValueStrategy {
fn series (&self, series: &Vec<f32>) -> Result<Vec<f32>,Box<dyn std::error::Error>> {
Ok(
series.iter()
.map(|y|(*y).clone())
.collect()
)
}
fn range (&self, ys:&Vec<f32>) -> Result<Range<f32>, Box<dyn std::error::Error>> {
let (y_min, y_max) = ys.iter()
.fold(
(0.0/0.0, 0.0/0.0),
|(m,n), v| (v.min(m), v.max(n))
);
// [MEMO]
// y軸の最大最小値を算出
// f32型はNaNが定義されていてys.iter().max()等が使えないので工夫が必要
// 参考サイト
// https://qiita.com/lo48576/items/343ca40a03c3b86b67cb
Ok(y_min..y_max)
}
}
が、もうすでに TwoDimentinalArray が f32 のベクタやイテレータを取り出すメソッドを用意してあるので、
series は意味がなかったな。。。と反省。
最後に文字列から DateTime に変換する関数です。
Date 構造体から DateTime 構造体への変更以外はオリジナルのままとなります。
pub fn parse_time(time_str: &str) -> DateTime<Local> {
Local.datetime_from_str(
&format!("{} 0:0", time_str),
"%Y-%m-%d %H:%M"
)
.unwrap()
// .date()
}
実行例
最後に実行例です。
コマンドライン:
c:\work\rust\myplot>target\debug\myplot.exe data.txt graph.png
出力された graph.png :