FlutterでGithubのヒートマップを作ってみた


# はじめに

みなさま、日頃Githubに草は生やしていますか？
実はあれ、正式名称は「ヒートマップ」と呼ぶらしいです。
今回の実装をするときに初めて知りました。。

ちなみに、みたことない方はいないと思いますがこのようなものです

![Screenshot 2023-10-07 0.47.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/34ebc1c7-557c-937d-f8ba-70e5db7fb4f0.png)

Githubは、「コントリビューション（貢献）」単位で草が生えます。
コミットやプッシュ回数も一つのカウントですが、コードレビューやプルリクエストのマージなどもその対象となり、1日の中でその回数が多いほど色が濃くなる仕組みです。

今回は、このヒートマップの簡易バージョンをFlutterで作ってみようと思い実装をしてみました

## デモ動画
### 表示状態
![Screenshot 2023-10-07 0.37.26.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/af6e0bc9-dc2b-af61-2fbb-34296a484c13.gif)

### セルのロングタップ時
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/b8e7d998-0af5-ef06-690c-52fc2e8ee8b5.png" width="250" >

## 使い方
極力簡易的に使えるようにしています（手を抜いただけ）
以下のパラメーターが指定できます
- data：日付データ一覧`List<Map<DateTime, int>>`で日付単位のスコア一覧を渡します
- colorSet：色付けの定義をします。`List<Map<int, Color>>`で基準値とそれを超えた時の色を定義できます
- defaultColor： スコア0のセルの色を定義できます
- cellSize：1つのセルの色を指定できます
- targetYear：年度の切り替えができるようにしています。別途`TextButton`など用意するのもいいですね

```dart
Heatmap(
  data: [
    // テストデータ
    {DateTime(2023, 1, 1): 1},
    {DateTime(2022, 1, 2): 2},
    {DateTime(2022, 1, 3): 3},
    {DateTime(2023, 1, 4): 4},
    {DateTime(2023, 1, 5): 5},
    // 3月中旬データ
    {DateTime(2023, 3, 1): 1},
    {DateTime(2023, 3, 2): 2},
    {DateTime(2023, 3, 3): 3},
    {DateTime(2023, 3, 4): 4},
    {DateTime(2023, 3, 5): 5},
    {DateTime(2022, 3, 6): 1},
    {DateTime(2022, 3, 7): 2},
    {DateTime(2022, 3, 8): 3},
    {DateTime(2022, 3, 9): 4},
    {DateTime(2022, 3, 10): 5},
    // 5月データ
    {DateTime(2023, 5, 1): 5},
    {DateTime(2023, 5, 2): 3},
    {DateTime(2023, 5, 3): 2},
    {DateTime(2023, 5, 4): 4},
    {DateTime(2023, 5, 5): 1},
    // 8月データ
    {DateTime(2023, 8, 1): 5},
    {DateTime(2023, 8, 2): 10},
    {DateTime(2023, 8, 3): 6},
    {DateTime(2023, 8, 4): 4},
    // 12月データ
    {DateTime(2023, 12, 1): 1},
    {DateTime(2023, 12, 2): 10},
    {DateTime(2023, 12, 3): 4},
    {DateTime(2023, 12, 4): 10},
    // ... 他のデータ
  ],
  colorSet: const {
    1: Color(0xFFD7ECBF),
    3: Color(0xFFB3E778),
    5: Color(0xFF239C27),
    10: Color(0xFF027C06),
  },
  cellSize: 14,
  targetYear: 2023,
),
```


## コード全体
今回は、パッケージなしでCustomPainterなどで作り切りました。
パラメーター含め、好きにカスタムして使っていただければと思います。

```dart
import 'package:flutter/material.dart';

/// `Heatmap` ウィジェットは日時データに基づいてヒートマップを表示します。
///
/// - [data]: ヒートマップのデータ。heat
/// - [colorSet]: 値ごとの色セット。
/// - [cellSize]: 各セルのサイズ。
/// - [defaultColor]: デフォルトのセル色。
/// - [targetYear]: ヒートマップの対象年。
///
class Heatmap extends StatefulWidget {
  const Heatmap({
    required this.data,
    required this.colorSet,
    super.key,
    this.cellSize = 16.0,
    this.defaultColor,
    this.targetYear,
  });
  final List<Map<DateTime, int>> data;
  final Map<int, Color> colorSet;
  final double cellSize;
  final Color? defaultColor;
  final int? targetYear;

  @override
  _HeatmapState createState() => _HeatmapState();
}

class _HeatmapState extends State<Heatmap> {
  DateTime? _selectedDate;
  late OverlayEntry _overlayEntry;

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      scrollDirection: Axis.horizontal,
      child: GestureDetector(
        onLongPress: () {
          // PopupMenuを表示
          _showPopupMenu(context);
        },
        onLongPressUp: () {
          if (_overlayEntry.mounted) {
            _overlayEntry.remove();
          }
        },
        child: SizedBox(
          height: (widget.cellSize * 7),
          child: CustomPaint(
            painter: HeatmapPainter(
              data: widget.data,
              colorSet: widget.colorSet,
              cellSize: widget.cellSize,
              targetYear: widget.targetYear,
              onTapCell: (date) {
                setState(() {
                  _selectedDate = date;
                });
              },
            ),
            size: Size(widget.cellSize * (365 ~/ 7), (widget.cellSize * 7)),
          ),
        ),
      ),
    );
  }

  void _showPopupMenu(BuildContext context) {
    if (_selectedDate == null) return;

    final overlay = Overlay.of(context);
    final overlayPosition = OverlayEntry(
      builder: (context) => Positioned(
        top: 140, // 位置は適切に調整してください。
        left: MediaQuery.sizeOf(context).width * 0.4, // 位置は適切に調整してください。
        child: Material(
          child: Container(
            padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
            decoration: BoxDecoration(
              color: Colors.white,
              borderRadius: BorderRadius.circular(4),
              boxShadow: const [
                BoxShadow(
                  color: Colors.black12,
                  offset: Offset(0, 2),
                  blurRadius: 6,
                ),
              ],
            ),
            child: Center(
              child: Text(
                _selectedDate!.toLocal().toIso8601String().split('T').first,
                style: const TextStyle(fontSize: 12),
              ),
            ),
          ),
        ),
      ),
    );

    overlay.insert(overlayPosition);
    _overlayEntry = overlayPosition;
  }
}

/// `HeatmapPainter` クラスは、ヒートマップのカスタム描画を行います。
///
/// - [data]: ヒートマップのデータ。
/// - [colorSet]: 値ごとの色セット。
/// - [cellSize]: 各セルのサイズ。
/// - [onTapCell]: セルがタップされたときのコールバック。
/// - [defaultColor]: デフォルトのセル色。
/// - [targetYear]: ヒートマップの対象年。
///
class HeatmapPainter extends CustomPainter {
  HeatmapPainter({
    required this.data,
    this.colorSet = const {},
    this.cellSize = 16.0,
    this.onTapCell,
    this.defaultColor,
    this.targetYear,
  });

  final List<Map<DateTime, int>> data;
  final Map<int, Color> colorSet;
  final double cellSize;
  final void Function(DateTime date)? onTapCell;
  final Color? defaultColor;
  final int? targetYear;

  @override
  bool? hitTest(Offset position) {
    // セルの位置を計算
    final week = (position.dx ~/ cellSize).toInt();
    final day = (position.dy ~/ cellSize).toInt();

    final targetYearNumber = targetYear ?? DateTime.now().year;

    final selectedDate =
        DateTime(targetYearNumber, 1, 1).add(Duration(days: (week * 7) + day));
    if (onTapCell != null) {
      onTapCell!(selectedDate);
    }

    return super.hitTest(position);
  }

  Color _getColorFromSet(int value) {
    final thresholds = colorSet.keys.toList()..sort();
    for (final threshold in thresholds) {
      if (value <= threshold) {
        return colorSet[threshold]!;
      }
    }
    return defaultColor ?? Colors.grey[300]!;
  }

  @override
  void paint(Canvas canvas, Size size) {
    const borderThickness = 0.5;

    final targetYearNumber = targetYear ?? DateTime.now().year;

    final currentYearStartDate = DateTime(targetYearNumber, 1, 1);

    // Filter data for the target year
    final filteredData = data
        .where((item) => item.keys.any((key) => key.year == targetYearNumber))
        .toList();

    for (var week = 0; week < (365 ~/ 7); week++) {
      for (var day = 0; day < 7; day++) {
        final x = week * cellSize;
        final y = day * cellSize;

        final currentCellDate =
            currentYearStartDate.add(Duration(days: (week * 7) + day));

        final matchingData = filteredData.firstWhere(
          (item) =>
              item.keys.any((key) => key.isAtSameMomentAs(currentCellDate)),
          orElse: () => {},
        );

        var cellColor = Colors.grey[300]!;
        if (matchingData.isNotEmpty) {
          final value = matchingData.values.first;
          cellColor = _getColorFromSet(value);
        }

        canvas.drawRect(
          Rect.fromPoints(
            Offset(x, y),
            Offset(
              x + cellSize - borderThickness,
              y + cellSize - borderThickness,
            ),
          ),
          Paint()..color = cellColor,
        );
      }
    }
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true;
  }
}
```