【Flutter & fl_chart】csvをグラフ表示する方法

## 背景
現在，ユーザに合わせて様々なOSに対応するアプリ開発をしようとする際に，クロスプラットフォーム開発環境を用いるのが一般的となっています．  
本記事は，Flutterを用いた開発でcsvの内容をグラフ表示した内容をまとめています．    


## アプリ概要
### 概要
ユーザが選択したcsvファイル(時間と速度がまとめられている)を読み込み，そのグラフを表示するアプリ．  

### 画面設計
画面上部にグラフ表示(csv読み込み前は何も表示しない)  
画面下部右に「csv読み込み」ボタン

### 操作
「csv読み込み」ボタンを押下すると，ローカルに保存されているcsvファイルを選択できる．(file_pickerライブラリを使用する)  
csvファイルを選択すると，そのcsvファイルを読み込む．(File クラスを使用する)  
csvファイルの内容を解析し，グラフに表示する．(fl_chartライブラリを使用する)
グラフは横軸を時間(s)，縦軸を速度(m/min)とする． 

### csvフォーマット
No.：5ms周期のカウンタ  
Speed：速度(cm/min)
(以下のような形式となる。)
| No. | Speed | 
| --- | ----- | 
| 1   | 0     | 
| 2   | 1     | 
| 3   | 2     | 
| ... | ...   | 


## ソースコード
csv読み込みとグラフ描写の処理を分けて記載しています．  
若干コメント入れています．

```yaml:pubspec.yaml
// 以下を付け足し
dependencies:
  flutter:
    sdk: flutter
  file_picker: ^8.1.2
  fl_chart: ^0.69.0
```

```dart:main.dart
// ■目的：CSVファイルから読み込んだデータ(No, cm/min)をグラフ表示するアプリ
//
// ■機能構成：
// 1. CSVファイルの選択(ファイルピッカー)
// 2. CSVデータの読み取り＆解析(Noを時間[s]に変換、cm/minをm/minに変換)
// 3. 折れ線グラフとして表示(fl_chartを使用)
//
// ■画面構成：
// - AppBar：タイトル表示
// - Body：
//   ├─ データがない → 説明文
//   ├─ データがある → 折れ線グラフ
//   └─ 右下：CSV読み込みボタン
//
// ■データ構造：
// - List<FlSpot> _dataPoints
//   各要素は(time[s], speed[m/min])を持つ
//
// ■CSV形式(例)：
//   No,Speed(cm/min)
//   1,120.5
//   2,130.0
//   ...

import 'dart:async';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(CsvGraphApp());
}

// アプリのルートWidget
class CsvGraphApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'CSV Graph Viewer',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: CsvGraphPage(),
    );
  }
}

// グラフ表示ページ（ステートフル）
class CsvGraphPage extends StatefulWidget {
  @override
  _CsvGraphPageState createState() => _CsvGraphPageState();
}

class _CsvGraphPageState extends State<CsvGraphPage> {
  // グラフに使うデータ点リスト（time[s], speed[m/min]）
  List<FlSpot> _dataPoints = [];

  /// CSVファイルを選択して読み込む処理
  Future<void> _pickAndLoadCsv() async {
    // 拡張子がcsvのファイルを選択
    FilePickerResult? result = await FilePicker.platform.pickFiles(
      type: FileType.custom,
      allowedExtensions: ['csv'],
    );

    if (result != null && result.files.single.path != null) {
      File file = File(result.files.single.path!);
      String content = await file.readAsString();
      _parseCsv(content);
    } else {
      // キャンセルされた場合にメッセージ表示
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('ファイルの選択がキャンセルされました。')),
      );
    }
  }

  /// CSVをパースして _dataPoints に格納する
  void _parseCsv(String content) {
    List<FlSpot> tempData = [];
    List<String> lines = content.split('\n');

    // ヘッダーを除いた2行目以降を処理
    for (int i = 1; i < lines.length; i++) {
      String line = lines[i].trim();
      if (line.isEmpty) continue; // 空行スキップ

      List<String> parts = line.split(',');
      if (parts.length < 2) continue; // 不完全な行はスキップ

      try {
        int no = int.parse(parts[0].trim());
        double speedCm = double.parse(parts[1].trim());

        double speed = speedCm / 100.0; // cm/min → m/min
        double timeInSeconds = (no * 5) / 1000.0; // 5ms刻み → 秒

        tempData.add(FlSpot(timeInSeconds, speed));
      } catch (e) {
        // 数値変換エラー時はスキップ
        continue;
      }
    }

    setState(() {
      _dataPoints = tempData;
    });
  }

  /// メインUI描画
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('CSVグラフ表示')),
      body: Column(
        children: [
          Expanded(
            child: _dataPoints.isEmpty
                ? Center(child: Text('CSVファイルを読み込んでください。'))
                : Padding(
                    padding: const EdgeInsets.all(16.0),
                    child: LineChart(
                      LineChartData(
                        // グリッド線の表示
                        gridData: FlGridData(show: true),

                        // 軸ラベルなどの設定
                        titlesData: FlTitlesData(
                          topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
                          rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
                          bottomTitles: AxisTitles(
                            sideTitles: SideTitles(
                              showTitles: true,
                              reservedSize: 30,
                              interval: _calculateXInterval(),
                              getTitlesWidget: (value, meta) => Padding(
                                padding: const EdgeInsets.only(top: 8.0),
                                child: Text('${value.toStringAsFixed(2)}'),
                              ),
                            ),
                            axisNameWidget: Padding(
                              padding: const EdgeInsets.only(top: 8.0),
                              child: Text('[s]'),
                            ),
                            axisNameSize: 20,
                          ),
                          leftTitles: AxisTitles(
                            sideTitles: SideTitles(
                              showTitles: true,
                              reservedSize: 40,
                              interval: _calculateYInterval(),
                              getTitlesWidget: (value, meta) =>
                                  Text('${value.toStringAsFixed(1)}'),
                            ),
                            axisNameWidget: Padding(
                              padding: const EdgeInsets.only(right: 8.0),
                              child: Text('[m/min]'),
                            ),
                            axisNameSize: 20,
                          ),
                        ),

                        // 枠線
                        borderData: FlBorderData(
                          show: true,
                          border: Border.all(color: Colors.black, width: 1),
                        ),

                        // グラフの表示範囲設定
                        minX: _dataPoints.first.x,
                        maxX: _dataPoints.last.x,
                        minY: _getMinY(),
                        maxY: _getMaxY(),

                        // 折れ線グラフの描画
                        lineBarsData: [
                          LineChartBarData(
                            spots: _dataPoints,
                            isCurved: false,
                            color: Colors.blue,
                            barWidth: 2,
                            dotData: FlDotData(show: false),
                          ),
                        ],
                      ),
                    ),
                  ),
          ),

          // ボタン：CSV読み込み
          SizedBox(height: 20),
          Align(
            alignment: Alignment.bottomRight,
            child: Padding(
              padding: const EdgeInsets.all(16.0),
              child: ElevatedButton.icon(
                onPressed: _pickAndLoadCsv,
                icon: Icon(Icons.file_upload),
                label: Text('csv読み込み'),
              ),
            ),
          ),
        ],
      ),
    );
  }

  /// Y軸の最小値（マージンあり）
  double _getMinY() {
    double minY = _dataPoints.map((e) => e.y).reduce((a, b) => a < b ? a : b);
    return minY - 1;
  }

  /// Y軸の最大値（マージンあり）
  double _getMaxY() {
    double maxY = _dataPoints.map((e) => e.y).reduce((a, b) => a > b ? a : b);
    return maxY + 1;
  }

  /// X軸の目盛り間隔を計算
  double _calculateXInterval() {
    double totalTime = _dataPoints.last.x - _dataPoints.first.x;
    return totalTime / 5;
  }

  /// Y軸の目盛り間隔を計算
  double _calculateYInterval() {
    double maxY = _getMaxY();
    return (maxY / 5).ceilToDouble();
  }
}
```

 
## 動作確認
macOS，iOS(エミュレータ)，Android(エミュレータ)，chromeで動作確認をしました．  
こちらの記事を参考にエミュレータを起動しました． 

https://qiita.com/Kento210/items/2c9a967b1b9451b5cbcd  

代表として，macOSで動作させた画像を貼っておきます．  
(エスカレーターのステップ速度を模擬したcsvを用意し、読み込みました)

![スクリーンショット 2024-09-29 21.04.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/295695/aab0a244-5c90-45bb-ab59-a2df0c4ccdb5.png)

![スクリーンショット 2024-09-29 21.05.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/295695/a85ee0be-4599-927e-f63d-07e93cc227a7.png)

![スクリーンショット 2024-09-29 21.05.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/295695/49a8c39b-ff62-3bac-02f1-117849d87cd1.png)


## おわりに
1つのソースコードから多くの環境で動作する実行ファイルが作成できるのは便利でした．  
chromeで動作させたときに，セキュリティの問題でローカルのcsvが読み込めなかったので，実行環境による動作の差異に注意が必要だと感じました．