🦋
ECharts 3D ScatterをFlutter Webで表示する
flutter_echartsはwebをサポートしていないので
import 'package:js/js.dart';
これで行きます。
まずはサンプルをそのまま表示
使うサンプルはこちら。
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^)/
うまくいけばね(>_<)
Discussion