// tools/statistical_charts.dart // // 统计图表:条形图 / 折线图 / 饼图 / 雷达图。 // // 这一页没有数学计算,只是把用户输入变成坐标,所以**不接 Symja**; // 数值用 double.tryParse 解析。包进 [ToolScaffold] 后,绘制由「计算」按钮触发, // 重置按钮也和其它工具一致。图表类型、曲线类型、增删数据行这些操作会换 key // 让外壳重建并重新计算,这样"点一下切换类型"仍然立刻重画。 import 'package:flutter/material.dart'; import 'package:fl_chart/fl_chart.dart'; import 'tool_kit.dart'; class StatisticalChartsPage extends StatefulWidget { const StatisticalChartsPage({super.key}); @override State createState() => _StatisticalChartsPageState(); } class _StatisticalChartsPageState extends State { static const List _chartTypes = ['条形图', '折线图', '饼图', '雷达图']; int _type = 0; /// 折线图是否用光滑曲线(false = 直线)。 bool _curved = true; /// 输入行:每两格是一行(标签、数值)。整条列表**就地增删**, /// 对象始终是同一个 —— 外壳靠 `identical(fields)` 判断要不要清空结果。 final List _fields = []; /// 图表类型 / 曲线类型 / 增删数据行后换 key,让外壳重建并 autoRun 立即重画。 int _runToken = 0; @override void initState() { super.initState(); for (var i = 0; i < 5; i++) { _addRow(initialLabel: '数据${i + 1}', initialValue: '${(i + 1) * 10}'); } } int get _rowCount => _fields.length ~/ 2; ToolField _labelAt(int i) => _fields[2 * i]; ToolField _valueAt(int i) => _fields[2 * i + 1]; void _addRow({String initialLabel = '新数据', String initialValue = '0'}) { final n = _rowCount + 1; final label = ToolField('第 $n 项标签', hint: '数据名'); final value = ToolField('第 $n 项数值', hint: '例如 10'); label.controller.text = initialLabel; value.controller.text = initialValue; _fields.addAll([label, value]); } void _onAddRow() => setState(() { _addRow(); _runToken++; }); void _onRemoveRow() { if (_rowCount <= 1) return; setState(() { _fields.removeRange(_fields.length - 2, _fields.length); _runToken++; }); } void _selectType(int i) { if (_type == i) return; setState(() { _type = i; _runToken++; }); } void _setCurved(bool value) { if (_curved == value) return; setState(() { _curved = value; _runToken++; }); } Widget _intro() => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: [ for (var i = 0; i < _chartTypes.length; i++) ChoiceChip( label: Text(_chartTypes[i]), selected: _type == i, onSelected: (_) => _selectType(i), ), ], ), if (_type == 1) ...[ const SizedBox(height: 12), Row( children: [ const Text('曲线类型:'), const SizedBox(width: 8), ChoiceChip( label: const Text('直线'), selected: !_curved, onSelected: (_) => _setCurved(false), ), const SizedBox(width: 8), ChoiceChip( label: const Text('光滑曲线'), selected: _curved, onSelected: (_) => _setCurved(true), ), ], ), ], const SizedBox(height: 12), Row( children: [ OutlinedButton.icon( onPressed: _onAddRow, icon: const Icon(Icons.add), label: const Text('添加数据'), ), const SizedBox(width: 12), OutlinedButton.icon( onPressed: _rowCount > 1 ? _onRemoveRow : null, icon: const Icon(Icons.remove), label: const Text('删除数据'), ), ], ), ], ); @override Widget build(BuildContext context) => ToolScaffold( key: ValueKey(_runToken), title: '统计图表绘制', autoRun: true, fields: _fields, intro: _intro(), compute: (session) async { // 把输入行读成 (标签, 数值)。这里没有数学计算,只是把输入变成坐标, // 所以解析留在 Dart 侧;非数字就抛中文异常。 final labels = []; final values = []; for (var i = 0; i < _rowCount; i++) { final label = _labelAt(i).text; final raw = _valueAt(i).text; if (label.isEmpty && raw.isEmpty) continue; if (raw.isEmpty) throw Exception('第 ${i + 1} 行的数值不能为空'); final v = double.tryParse(raw); if (v == null || !v.isFinite) { throw Exception('第 ${i + 1} 行的「$raw」不是有效数字'); } values.add(v); labels.add(label.isEmpty ? '第${i + 1}项' : label); } if (values.isEmpty) { throw Exception('请至少填写一行有效数据'); } final chart = switch (_type) { 1 => _lineChart(values, labels), 2 => _pieChart(values), 3 => _radarChart(values, labels), _ => _barChart(values, labels), }; return [ ToolChart( _chartTypes[_type], // fl_chart 需要一个确定高度,否则在无界的高度里会报错。 SizedBox(height: 320, child: chart), ), ]; }, ); /// 坐标轴的"好看"上限:最大值再留 20% 余量;全零或全负时给个 1,免得轴退化。 static double _niceMax(List values) { var maxV = values.first; for (final v in values) { if (v > maxV) maxV = v; } return maxV <= 0 ? 1 : maxV * 1.2; } static SideTitles _bottomTitles(List labels) => SideTitles( showTitles: true, getTitlesWidget: (value, meta) { final index = value.toInt(); if (index < 0 || index >= labels.length) { return const SizedBox.shrink(); } return Padding( padding: const EdgeInsets.only(top: 8), child: Text( labels[index], style: const TextStyle(fontSize: 10), maxLines: 2, overflow: TextOverflow.ellipsis, ), ); }, ); Widget _barChart(List values, List labels) => BarChart( BarChartData( alignment: BarChartAlignment.spaceAround, maxY: _niceMax(values), barTouchData: BarTouchData( enabled: true, touchTooltipData: const BarTouchTooltipData(), ), titlesData: FlTitlesData( show: true, bottomTitles: AxisTitles(sideTitles: _bottomTitles(labels)), leftTitles: const AxisTitles( sideTitles: SideTitles(showTitles: true, reservedSize: 40), ), ), gridData: const FlGridData(show: true), borderData: FlBorderData(show: true), barGroups: [ for (var i = 0; i < values.length; i++) BarChartGroupData( x: i, barRods: [ BarChartRodData( toY: values[i], color: Colors.primaries[i % Colors.primaries.length], width: 16, ), ], ), ], ), ); Widget _lineChart(List values, List labels) => LineChart( LineChartData( lineTouchData: const LineTouchData(enabled: true), gridData: const FlGridData(show: true), titlesData: FlTitlesData( show: true, bottomTitles: AxisTitles(sideTitles: _bottomTitles(labels)), leftTitles: const AxisTitles( sideTitles: SideTitles(showTitles: true, reservedSize: 40), ), ), borderData: FlBorderData(show: true), minX: 0, maxX: values.length > 1 ? (values.length - 1).toDouble() : 1, minY: 0, maxY: _niceMax(values), lineBarsData: [ LineChartBarData( spots: [ for (var i = 0; i < values.length; i++) FlSpot(i.toDouble(), values[i]), ], isCurved: _curved, color: Colors.blue, barWidth: 4, belowBarData: BarAreaData(show: false), dotData: FlDotData( show: true, getDotPainter: (spot, percent, barData, index) => FlDotCirclePainter( radius: 4, color: Colors.blue, strokeWidth: 2, strokeColor: Colors.white, ), ), ), ], ), ); Widget _pieChart(List values) { var total = 0.0; for (final v in values) { total += v; } return PieChart( PieChartData( sectionsSpace: 4, centerSpaceRadius: 40, sections: [ for (var i = 0; i < values.length; i++) PieChartSectionData( color: Colors.primaries[i % Colors.primaries.length], value: values[i], // 百分比:数据本身没问题时才算,全零就留空。 title: total == 0 ? '' : '${(values[i] / total * 100).toStringAsFixed(1)}%', radius: 50, titleStyle: const TextStyle( fontSize: 12, fontWeight: FontWeight.bold, color: Colors.white, ), ), ], ), ); } Widget _radarChart(List values, List labels) { if (values.length < 3) { return const Center( child: Text( '雷达图需要至少 3 个数据点\n请添加更多数据', textAlign: TextAlign.center, style: TextStyle(fontSize: 16, color: Colors.grey), ), ); } return RadarChart( RadarChartData( dataSets: [ RadarDataSet( dataEntries: [ for (final v in values) RadarEntry(value: v), ], fillColor: Colors.blue.withValues(alpha: 0.3), borderColor: Colors.blue, borderWidth: 2, ), ], radarBackgroundColor: Colors.transparent, radarBorderData: const BorderSide(color: Colors.grey, width: 1), tickBorderData: const BorderSide(color: Colors.grey, width: 1), gridBorderData: const BorderSide(color: Colors.grey, width: 1), ticksTextStyle: const TextStyle(color: Colors.black, fontSize: 10), tickCount: 5, getTitle: (index, angle) { if (index >= 0 && index < labels.length) { return RadarChartTitle(text: labels[index], angle: angle); } return const RadarChartTitle(text: ''); }, titleTextStyle: const TextStyle( color: Colors.black, fontSize: 12, fontWeight: FontWeight.bold, ), titlePositionPercentageOffset: 0.15, ), swapAnimationDuration: const Duration(milliseconds: 500), swapAnimationCurve: Curves.easeInOut, ); } }