1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SwitchBot + FastAPI + Flutter で家庭の電力消費を可視化するWebアプリ構築(その5)

Last updated at Posted at 2026-01-20

fl_chart で電力消費グラフを描画する - 時系列データの可視化

📝 はじめに

この記事は
SwitchBot + FastAPI + Flutter で家庭の電力消費を可視化するWebアプリ構築(その4)- Qiita
の続きになります。

:white_check_mark: この記事で書くこと

  • fl_chart ライブラリによる折れ線グラフの実装
  • デバイス別データの分離と色分け表示
  • 統計情報(平均・最大・最小)の計算と表示

:white_check_mark: 対象読者

  • fl_chart でグラフを描画したい方
  • 時系列データを可視化したい方
  • Flutter でダッシュボードを作りたい方

:white_check_mark: 前提

  • Flutter / Dart の基本知識
  • 前回の記事で構築したアプリ構造

🎯 背景・動機

なぜ fl_chart を選んだのか

:thinking: 課題
電力データを時系列グラフで表示し、デバイス別に色分けしたい。

:bulb: fl_chart を選んだ理由

ライブラリ 特徴
fl_chart Flutter ネイティブ、高カスタマイズ性
charts_flutter Google 製、シンプル
syncfusion_flutter_charts 高機能、商用ライセンス

:point_right: カスタマイズ性が高く、無料で使える 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)),
  );
}

:white_check_mark: ポイント

  • 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),  // 塗りつぶし
    ),
  );
}

:white_check_mark: オプション解説

オプション 説明
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);
}

:point_right: 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),
      ),
    ],
  );
}

📊 実行結果 & コツ

完成イメージ

scrgra.png

┌─────────────────────────────────────┐
│  SwitchBot Power Monitor            │
├─────────────────────────────────────┤
│  SwitchBot サーバー                 │
│  ╭───────────────────────╮          │
│  │    ∿∿∿∿∿∿∿∿∿∿∿       │  緑      │
│  │  ∿            ∿∿∿    │          │
│  ╰───────────────────────╯          │
│  平均: 45.2W  最大: 52.1W  最小: 38.5W │
├─────────────────────────────────────┤
│  SwitchBot ディスプレイ             │
│  ╭───────────────────────╮          │
│  │  ∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿   │  紫      │
│  ╰───────────────────────╯          │
│  平均: 23.1W  最大: 25.0W  最小: 21.5W │
└─────────────────────────────────────┘

つまずきポイントと回避策

:x: つまずき1: X 軸のラベルが重なる

:white_check_mark: 回避策: interval を設定して間引く

sideTitles: SideTitles(
  showTitles: true,
  interval: (deviceData.length / 5).toDouble(),
),

:x: つまずき2: データが空のときにエラー

:white_check_mark: 回避策: 空チェックを追加

if (deviceData.isEmpty) {
  return Center(child: Text('データがありません'));
}

:x: つまずき3: Y 軸の最大値が固定で見づらい

:white_check_mark: 回避策: データに応じた動的スケーリング

maxY: _getMaxPower(deviceData) * 1.1,  // 10% 余裕

:x: つまずき4: 時系列が逆順になる

:white_check_mark: 回避策: ソートを追加

data.sort((a, b) =>
  DateTime.parse(a['created_at']).compareTo(DateTime.parse(b['created_at']))
);

📝 まとめ

学んだこと

:white_check_mark: fl_chart のポイント

  • LineChartBarData でデータ系列を定義
  • isCurved: true で滑らかな曲線
  • belowBarData でグラデーション塗りつぶし
  • FlTitlesData で軸ラベルをカスタマイズ

:white_check_mark: データ処理

  • デバイス ID でデータを分離
  • 時系列順にソート
  • 統計値の計算(平均・最大・最小)

:white_check_mark: UI 設計

  • 複数グラフの並列表示
  • 統計情報の見やすい配置
  • リフレッシュボタンで手動更新

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?