🦋

ECharts 3D ScatterをFlutter Webで表示する

2024/12/18に公開

flutter_echartsはwebをサポートしていないので

import 'package:js/js.dart';

これで行きます。

まずはサンプルをそのまま表示

使うサンプルはこちら。
https://echarts.apache.org/examples/en/editor.html?c=scatter3d&gl=1&theme=dark

pubspec.yaml
  js: ^0.7.1
  http: ^1.2.2

爺様のいうとおり、玉手箱・・・httpは、ファイルのほうで「要らない」って言われるけど、
まあ、とりあえず入れておく。

index.html
  <!-- Include ECharts -->
  <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
  <!-- Include ECharts-GL for 3D support -->
  <script src="https://cdn.jsdelivr.net/npm/echarts-gl@2/dist/echarts-gl.min.js"></script>
  <!-- Your custom JavaScript for initializing the chart -->
  <script>
    function initChart(chartId, optionsJson) {
      const chartDom = document.getElementById(chartId);
      if (chartDom) {
        const myChart = echarts.init(chartDom, 'dark');
        const options = JSON.parse(optionsJson);
        myChart.setOption(options);
      } else {
        console.error('Div element not found:', chartId);
      }
    }
  </script>

ここまでは<head>の中
次は<body>に追記

<script src="main.dart.js" type="application/javascript"></script>

黄色線が出るけど、放置。

何もかもmain.dartに突っ込む

最初からファイル構成とか考えていろいろやって、ドツボにハマったので、
まずはできるだけ簡便、を目指す。

main.dart
main.dart
import 'dart:convert';
import 'dart:html' as html;
import 'dart:ui' as ui;

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:js/js.dart';

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

/// Annotate the Dart function to bind it to the JavaScript `initChart` function.
/// This allows Dart to call the JavaScript function directly.
('initChart')
external void initChart(String chartId, String optionsJson);

class MyApp extends StatefulWidget {
  
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  // Define a unique view type for the ECharts container
  final String _viewType = 'echarts-div';

  
  void initState() {
    super.initState();

    // Register the view factory for the ECharts container
    // ignore: undefined_prefixed_name
    ui.platformViewRegistry.registerViewFactory(_viewType, (int viewId) {
      // Create a DivElement to host the ECharts chart
      final html.DivElement div = html.DivElement()
        ..id = 'echarts_div'
        ..style.width = '100%'
        ..style.height = '600px'; // Adjust height as needed

      // Initialize the chart after the div is attached to the DOM
      Future.microtask(() async {
        await _initializeChart();
      });

      return div;
    });
  }

  Future<void> _initializeChart() async {
    try {
      // ローカルのassetsフォルダにあるJSONファイルを読み込む
      final jsonString = await rootBundle.loadString('assets/nutrients.json');
      final List<dynamic> data = json.decode(jsonString);

      // Generate the ECharts option based on the fetched data
      final Map<String, dynamic> options = _generateChartOptions(data);

      // Convert the options map to a JSON string
      final String optionsJson = json.encode(options);

      // Call the JavaScript function to initialize the chart
      initChart('echarts_div', optionsJson);
    } catch (e) {
      print('Error initializing chart: $e');
    }
  }

  /// Generates the ECharts configuration based on the provided data
  Map<String, dynamic> _generateChartOptions(List<dynamic> data) {
    // Define field indices as per the original JavaScript code
    final Map<String, int> fieldIndices = {
      'name': 0,
      'group': 1,
      'protein': 2,
      'calcium': 3,
      'sodium': 4,
      'fiber': 5,
      'vitaminc': 6,
      'potassium': 7,
      'carbohydrate': 8,
      'sugars': 9,
      'fat': 10,
      'water': 11,
      'calories': 12,
      'saturated': 13,
      'monounsat': 14,
      'polyunsat': 15,
      'id': 16,
    };

    // Configuration parameters similar to the original JS config
    final Map<String, String> config = {
      'xAxis3D': 'protein',
      'yAxis3D': 'fiber',
      'zAxis3D': 'sodium',
      'color': 'fiber',
      'symbolSize': 'vitaminc',
    };

    // Prepare the data for ECharts
    final List<List<dynamic>> seriesData = data.map((item) {
      return [
        item[fieldIndices[config['xAxis3D']]!],
        item[fieldIndices[config['yAxis3D']]!],
        item[fieldIndices[config['zAxis3D']]!],
        item[fieldIndices[config['color']]!],
        item[fieldIndices[config['symbolSize']]!],
        item[fieldIndices['id']]!,
      ];
    }).toList();

    double colorMax = data.fold<double>(
      -double.infinity,
          (prev, item) {
        final colorVal = item[fieldIndices[config['color']]!];
        double doubleVal;

        if (colorVal == null) {
          // null値は比較のために極小値(-double.infinity)にしておく
          doubleVal = -double.infinity;
        } else if (colorVal is num) {
          doubleVal = colorVal.toDouble();
        } else {
          // 数字でなければparseしてみる。失敗したら極小値
          doubleVal = double.tryParse(colorVal.toString()) ?? -double.infinity;
        }

        return (doubleVal > prev) ? doubleVal : prev;
      },
    );

    double symbolSizeMax = data.fold<double>(
      -double.infinity,
          (prev, item) {
        final sizeVal = item[fieldIndices[config['symbolSize']]!];
        double doubleVal;

        if (sizeVal == null) {
          doubleVal = -double.infinity;
        } else if (sizeVal is num) {
          doubleVal = sizeVal.toDouble();
        } else {
          doubleVal = double.tryParse(sizeVal.toString()) ?? -double.infinity;
        }

        return (doubleVal > prev) ? doubleVal : prev;
      },
    );


    // Define the ECharts option
    final Map<String, dynamic> option = {
      'tooltip': {},
      'visualMap': [
        {
          'top': 10,
          'calculable': true,
          'dimension': 3,
          'max': colorMax / 2,
          'inRange': {
            'color': [
              '#1710c0',
              '#0b9df0',
              '#00fea8',
              '#00ff0d',
              '#f5f811',
              '#f09a09',
              '#fe0300'
            ]
          },
          'textStyle': {
            'color': '#fff',
          },
        },
        {
          'bottom': 10,
          'calculable': true,
          'dimension': 4,
          'max': symbolSizeMax / 2,
          'inRange': {
            'symbolSize': [10, 40],
          },
          'textStyle': {
            'color': '#fff',
          },
        },
      ],
      'xAxis3D': {
        'name': config['xAxis3D'],
        'type': 'value',
      },
      'yAxis3D': {
        'name': config['yAxis3D'],
        'type': 'value',
      },
      'zAxis3D': {
        'name': config['zAxis3D'],
        'type': 'value',
      },
      'grid3D': {
        'axisLine': {
          'lineStyle': {
            'color': '#fff',
          },
        },
        'axisPointer': {
          'lineStyle': {
            'color': '#ffbd67',
          },
        },
        'viewControl': {
          // 'autoRotate': true,
          // 'projection': 'orthographic',
        },
      },
      'series': [
        {
          'type': 'scatter3D',
          'dimensions': [
            config['xAxis3D'],
            config['yAxis3D'],
            config['zAxis3D'],
            config['color'],
            config['symbolSize'],
          ],
          'data': seriesData,
          'symbolSize': 12,
          'itemStyle': {
            'borderWidth': 1,
            'borderColor': 'rgba(255,255,255,0.8)',
          },
          'emphasis': {
            'itemStyle': {
              'color': '#fff',
            },
          },
        },
      ],
    };

    return option;
  }

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'ECharts Flutter Web',
      home: Scaffold(
        appBar: AppBar(
          title: const Text('ECharts 3D Scatter Plot in Flutter Web'),
        ),
        body: HtmlElementView(viewType: _viewType),
      ),
    );
  }
}

基本的にはサンプルコードのままなのだけど、変えたところ。
表示するデータをECahrtsサイトから読み込む形がうまくいかなかったので、
全部コピってきてassetsに入れた。8000行近くあった。
データサイエンス系は、ともかくデータが多くて大変。
データの中にあるnullがいちいち引っかかるので、それを0に置き換えるコードをプラス。

サンプルの8000行のデータを、自前の5000行に差し替える

自前のデータをアセットに入れる。
それを読んで、表示する。
相変わらず、全部main.dartに突っ込んだまま。

main.dart
main.dart
import 'dart:convert';
import 'dart:html' as html;
import 'dart:ui_web' as ui_web;

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:js/js.dart';

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

/// Annotate the Dart function to bind it to the JavaScript `initChart` function.
/// This allows Dart to call the JavaScript function directly.
('initChart')
external void initChart(String chartId, String optionsJson);

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  
  MyAppState createState() => MyAppState();
}

class MyAppState extends State<MyApp> {
  // Define a unique view type for the ECharts container
  final String _viewType = 'echarts-div';

  
  void initState() {
    super.initState();

    // Register the view factory for the ECharts container
    // ignore: undefined_prefixed_name
    ui_web.platformViewRegistry.registerViewFactory(_viewType, (int viewId) {
      // Create a DivElement to host the ECharts chart
      final html.DivElement div = html.DivElement()
        ..id = 'echarts_div'
        ..style.width = '100%'
        ..style.height = '600px'; // Adjust height as needed

      // Initialize the chart after the div is attached to the DOM
      Future.microtask(() async {
        await _initializeChart();
      });

      return div;
    });
  }

  Future<void> _initializeChart() async {
    try {
      final String coastLineString = await rootBundle.loadString('assets/coastline.json');
      final List<dynamic> coastLineData = json.decode(coastLineString);
      final String ridgeLineString = await rootBundle.loadString('assets/ridge.json');
      final List<dynamic> ridgeLineData = json.decode(ridgeLineString);
      final String trenchLineString = await rootBundle.loadString('assets/trench.json');
      final List<dynamic> trenchLineData = json.decode(trenchLineString);

      // [経度, 緯度] → [経度, 緯度, 0.0]への変換
      final transformedCoast = coastLineData.map((item) {
        final double lon = (item[0] is num) ? (item[0].toDouble()) : double.tryParse(item[0].toString()) ?? 0.0;
        final double lat = (item[1] is num) ? (item[1].toDouble()) : double.tryParse(item[1].toString()) ?? 0.0;
        return [lon, lat, 0.0];
      }).toList();

      final transformedRidge = ridgeLineData.map((item) {
        final double lon = (item[0] is num) ? (item[0].toDouble()) : double.tryParse(item[0].toString()) ?? 0.0;
        final double lat = (item[1] is num) ? (item[1].toDouble()) : double.tryParse(item[1].toString()) ?? 0.0;
        return [lon, lat, 0.0];
      }).toList();

      final transformedTrench = trenchLineData.map((item) {
        final double lon = (item[0] is num) ? (item[0].toDouble()) : double.tryParse(item[0].toString()) ?? 0.0;
        final double lat = (item[1] is num) ? (item[1].toDouble()) : double.tryParse(item[1].toString()) ?? 0.0;
        return [lon, lat, 0.0];
      }).toList();

      // X軸: -180~180、Y軸: -90~90、Z軸: -1~1
      // splitNumberを設定しおおよその分割数を指定。
      // (ECHartsがぴったり30刻みのラベルを出すとは限りませんが、近い数で分割されます)
      final Map<String, dynamic> option = {
        'tooltip': {},
        'xAxis3D': {
          'type': 'value',
          'name': 'Longitude',
          'min': -180,
          'max': 180,
          'splitNumber': 12 // 360/30=12分割
        },
        'yAxis3D': {
          'type': 'value',
          'name': 'Latitude',
          'min': -90,
          'max': 90,
          'splitNumber': 6 // 180/30=6分割
        },
        'zAxis3D': {
          'type': 'value',
          'name': 'Timeline',
          'min': -1,
          'max': 1,
          'splitNumber': 2 // 0を中心に-1~1を2分割
        },
        'grid3D': {
          'axisLine': {
            'lineStyle': {
              'color': '#fff',
            },
          },
          'axisPointer': {
            'lineStyle': {
              'color': '#ffbd67',
            },
          },
          // 横長表示にするため箱サイズを調整
          // boxWidthがX方向、boxDepthがZ方向、boxHeightがY方向に対応(デフォルトは1)
          // 横長にしたい場合、X方向を他より大きく
          'boxWidth': 360,  // 東西を広く
          'boxDepth': 180,  // 南北は半分
          'boxHeight': 180, // 時間軸
          'viewControl': {
            'projection': 'orthographic'
          },
        },
        'series': [
          {
            'type': 'scatter3D',
            'data': transformedCoast,
            'symbolSize': 3,
            'itemStyle': {
              'color': 'white'
            }
          },
          {
            'type': 'scatter3D',
            'data': transformedRidge,
            'symbolSize': 3,
            'itemStyle': {
              'color': '#bc8f8f'
            }
          },
          {
            'type': 'scatter3D',
            'data': transformedTrench,
            'symbolSize': 3,
            'itemStyle': {
              'color': '#cd5c5c'
            }
          },
        ],
      };

      final String optionJson = json.encode(option);
      initChart('echarts_div', optionJson);
    } catch (e) {
      print('Error initializing chart: $e');
    }
  }

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'ECharts Flutter Web',
      home: Scaffold(
        appBar: AppBar(
          title: const Text('ECharts 3D Scatter Plot in Flutter Web'),
        ),
        body: HtmlElementView(viewType: _viewType),
      ),
    );
  }
}


これが

こうなった。

データベースから取ったデータを表示する

と、思ったのだが、ここをワンステップと考えると沼るので、分解する。

データを取るために

本番では、検索項目選んだり、いろいろやって取得するのだが、
今回はいきなり繋いで、引数で指定したIDのデータを拾ってくる
という形でやってみる。
そのために、関数を書いて、関数を起動するボタンを置く。

fetch_with_map.dart
import 'package:acorn_client/acorn_client.dart';
import 'package:flutter/material.dart';
import '../serverpod_client.dart';

class FetchWithMapRepository {
  List<WithMap> listWithMap = [];
  List<int> withMapIds = [];

  Future<void> fetchWithMap({List<int>? keyNumbers}) async {
    try {
      listWithMap = await client.withMap.getWithMap(keyNumbers: keyNumbers);
      withMapIds = listWithMap.map((item) => item.id as int).toList();
      print('Fetched listWithMap: $listWithMap');
    } on Exception catch (e) {
      debugPrint('$e');
    }
  }
}

main.dartに上記をimportしてから

main.dart
  final FetchWithMapRepository _repository = FetchWithMapRepository();

  void _onFetchWithMapButtonPressed() {
    _repository.fetchWithMap(keyNumbers: [100, 101, 102, 103, 104]);
  }

中略

            Padding(
              padding: const EdgeInsets.only(bottom: 100),
              child: ElevatedButton(
                  onPressed: _onFetchWithMapButtonPressed,
                  child: const Text('Get Data')),
            )

これでデータが取れた。めでたい。
めでたいけど、これを表示できるか、それが問題だ。

ここから先は、AdventCalendarに\(^O^)/

うまくいけばね(>_<)

Flutter大学

Discussion