fl_chart で電力消費グラフを描画する - 時系列データの可視化
📝 はじめに
この記事は
SwitchBot + FastAPI + Flutter で家庭の電力消費を可視化するWebアプリ構築(その4)- Qiita
の続きになります。
この記事で書くこと
- fl_chart ライブラリによる折れ線グラフの実装
- デバイス別データの分離と色分け表示
- 統計情報(平均・最大・最小)の計算と表示
対象読者
- fl_chart でグラフを描画したい方
- 時系列データを可視化したい方
- Flutter でダッシュボードを作りたい方
前提
- Flutter / Dart の基本知識
- 前回の記事で構築したアプリ構造
🎯 背景・動機
なぜ fl_chart を選んだのか
課題
電力データを時系列グラフで表示し、デバイス別に色分けしたい。
fl_chart を選んだ理由
| ライブラリ | 特徴 |
|---|---|
| fl_chart | Flutter ネイティブ、高カスタマイズ性 |
| charts_flutter | Google 製、シンプル |
| syncfusion_flutter_charts | 高機能、商用ライセンス |
カスタマイズ性が高く、無料で使える fl_chart を選択しました。
🛠️ 手順/解説
fl_chart の基本構造
LineChart(
LineChartData(
gridData: FlGridData(show: true), // グリッド線
titlesData: FlTitlesData(...), // 軸ラベル
borderData: FlBorderData(show: true), // 枠線
minX: 0,
maxX: 100,
minY: 0,
maxY: 200,
lineBarsData: [...], // データ系列
),
)
グラフウィジェットの実装
Widget _buildDeviceChart(
String title,
List<Map<String, dynamic>> deviceData,
Color lineColor,
Color fillColor,
) {
if (deviceData.isEmpty) {
return Center(child: Text('$title: データなし'));
}
return Column(
children: [
// タイトル
Padding(
padding: EdgeInsets.all(8.0),
child: Text(
title,
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
),
// グラフ
Expanded(
child: Padding(
padding: EdgeInsets.all(16.0),
child: LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: true,
horizontalInterval: 20,
),
titlesData: _buildTitlesData(deviceData),
borderData: FlBorderData(show: true),
minX: 0,
maxX: (deviceData.length - 1).toDouble(),
minY: 0,
maxY: _getMaxPower(deviceData) * 1.1,
lineBarsData: [
_buildLineChartBarData(deviceData, lineColor, fillColor),
],
),
),
),
),
// 統計情報
_buildStatistics(deviceData),
],
);
}
軸ラベルの設定
FlTitlesData _buildTitlesData(List<Map<String, dynamic>> deviceData) {
return FlTitlesData(
// X軸(時間)
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
interval: (deviceData.length / 5).toDouble(), // 5分割
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index >= 0 && index < deviceData.length) {
final createdAt = deviceData[index]['created_at'];
if (createdAt != null) {
final time = DateTime.parse(createdAt);
return Padding(
padding: EdgeInsets.only(top: 8),
child: Text(
'${time.hour}:${time.minute.toString().padLeft(2, '0')}',
style: TextStyle(fontSize: 10),
),
);
}
}
return Text('');
},
),
),
// Y軸(電力値)
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 50,
getTitlesWidget: (value, meta) {
return Text(
'${value.toInt()}W',
style: TextStyle(fontSize: 10),
);
},
),
),
// 上と右は非表示
topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
);
}
ポイント
-
interval: ラベルの間隔を設定(重なり防止) -
reservedSize: ラベル領域のサイズ -
padLeft(2, '0'): 分を2桁表示(例: 9 → 09)
データ系列の設定
LineChartBarData _buildLineChartBarData(
List<Map<String, dynamic>> deviceData,
Color lineColor,
Color fillColor,
) {
return LineChartBarData(
spots: _getPowerSpots(deviceData),
isCurved: true, // 滑らかな曲線
color: lineColor,
barWidth: 2,
isStrokeCapRound: true,
dotData: FlDotData(show: false), // ドットを非表示
belowBarData: BarAreaData(
show: true,
color: fillColor.withOpacity(0.3), // 塗りつぶし
),
);
}
オプション解説
| オプション | 説明 |
|---|---|
isCurved: true |
曲線で描画(false だと折れ線) |
barWidth |
線の太さ |
dotData |
データポイントの表示設定 |
belowBarData |
グラフ下部の塗りつぶし |
データ変換(FlSpot への変換)
List<FlSpot> _getPowerSpots(List<Map<String, dynamic>> deviceData) {
return deviceData.asMap().entries.map((entry) {
final index = entry.key.toDouble(); // X座標
final power = (entry.value['power_consumption'] ?? 0).toDouble(); // Y座標
return FlSpot(index, power);
}).toList();
}
最大値の取得(Y軸スケーリング用)
double _getMaxPower(List<Map<String, dynamic>> deviceData) {
if (deviceData.isEmpty) return 100;
return deviceData
.map((d) => (d['power_consumption'] ?? 0).toDouble())
.reduce((a, b) => a > b ? a : b);
}
maxY: _getMaxPower(deviceData) * 1.1 で 10% の余裕を持たせています。
デバイス別データの分離
Future<void> loadPowerData() async {
final data = await SupabaseService.fetchLatestPowerData(limit: 100);
// デバイス ID で分離
final server = <Map<String, dynamic>>[];
final display = <Map<String, dynamic>>[];
for (final record in data) {
final deviceId = record['device_id'] as String? ?? '';
if (deviceId == '6867259B00F2') {
server.add(record);
} else if (deviceId == 'DCDA0CD9AA3A') {
display.add(record);
}
}
// 時系列順にソート(古い順)
server.sort((a, b) =>
DateTime.parse(a['created_at']).compareTo(DateTime.parse(b['created_at']))
);
display.sort((a, b) =>
DateTime.parse(a['created_at']).compareTo(DateTime.parse(b['created_at']))
);
setState(() {
serverData = server;
displayData = display;
isLoading = false;
});
}
複数グラフの表示
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('電力消費グラフ')),
body: isLoading
? Center(child: CircularProgressIndicator())
: Column(
children: [
// サーバーグラフ(上半分)
Expanded(
flex: 1,
child: _buildDeviceChart(
'SwitchBot サーバー',
serverData,
Colors.green.shade600,
Colors.green.shade100,
),
),
Divider(height: 1),
// ディスプレイグラフ(下半分)
Expanded(
flex: 1,
child: _buildDeviceChart(
'SwitchBot ディスプレイ',
displayData,
Colors.purple.shade600,
Colors.purple.shade100,
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: loadPowerData,
child: Icon(Icons.refresh),
),
);
}
統計情報の表示
Widget _buildStatistics(List<Map<String, dynamic>> deviceData) {
if (deviceData.isEmpty) return SizedBox.shrink();
final powers = deviceData
.map((d) => (d['power_consumption'] ?? 0).toDouble())
.toList();
final avg = powers.reduce((a, b) => a + b) / powers.length;
final max = powers.reduce((a, b) => a > b ? a : b);
final min = powers.reduce((a, b) => a < b ? a : b);
return Padding(
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStatItem('平均', avg, Colors.blue),
_buildStatItem('最大', max, Colors.red),
_buildStatItem('最小', min, Colors.green),
],
),
);
}
Widget _buildStatItem(String label, double value, Color color) {
return Column(
children: [
Text(label, style: TextStyle(fontSize: 12, color: Colors.grey)),
Text(
'${value.toStringAsFixed(1)}W',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: color),
),
],
);
}
📊 実行結果 & コツ
完成イメージ
┌─────────────────────────────────────┐
│ SwitchBot Power Monitor │
├─────────────────────────────────────┤
│ SwitchBot サーバー │
│ ╭───────────────────────╮ │
│ │ ∿∿∿∿∿∿∿∿∿∿∿ │ 緑 │
│ │ ∿ ∿∿∿ │ │
│ ╰───────────────────────╯ │
│ 平均: 45.2W 最大: 52.1W 最小: 38.5W │
├─────────────────────────────────────┤
│ SwitchBot ディスプレイ │
│ ╭───────────────────────╮ │
│ │ ∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿ │ 紫 │
│ ╰───────────────────────╯ │
│ 平均: 23.1W 最大: 25.0W 最小: 21.5W │
└─────────────────────────────────────┘
つまずきポイントと回避策
つまずき1: X 軸のラベルが重なる
回避策: interval を設定して間引く
sideTitles: SideTitles(
showTitles: true,
interval: (deviceData.length / 5).toDouble(),
),
つまずき2: データが空のときにエラー
回避策: 空チェックを追加
if (deviceData.isEmpty) {
return Center(child: Text('データがありません'));
}
つまずき3: Y 軸の最大値が固定で見づらい
回避策: データに応じた動的スケーリング
maxY: _getMaxPower(deviceData) * 1.1, // 10% 余裕
つまずき4: 時系列が逆順になる
回避策: ソートを追加
data.sort((a, b) =>
DateTime.parse(a['created_at']).compareTo(DateTime.parse(b['created_at']))
);
📝 まとめ
学んだこと
fl_chart のポイント
-
LineChartBarDataでデータ系列を定義 -
isCurved: trueで滑らかな曲線 -
belowBarDataでグラデーション塗りつぶし -
FlTitlesDataで軸ラベルをカスタマイズ
データ処理
- デバイス ID でデータを分離
- 時系列順にソート
- 統計値の計算(平均・最大・最小)
UI 設計
- 複数グラフの並列表示
- 統計情報の見やすい配置
- リフレッシュボタンで手動更新
