| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365 |
- // 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<StatisticalChartsPage> createState() => _StatisticalChartsPageState();
- }
- class _StatisticalChartsPageState extends State<StatisticalChartsPage> {
- static const List<String> _chartTypes = <String>['条形图', '折线图', '饼图', '雷达图'];
- int _type = 0;
- /// 折线图是否用光滑曲线(false = 直线)。
- bool _curved = true;
- /// 输入行:每两格是一行(标签、数值)。整条列表**就地增删**,
- /// 对象始终是同一个 —— 外壳靠 `identical(fields)` 判断要不要清空结果。
- final List<ToolField> _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<int>(_runToken),
- title: '统计图表绘制',
- autoRun: true,
- fields: _fields,
- intro: _intro(),
- compute: (session) async {
- // 把输入行读成 (标签, 数值)。这里没有数学计算,只是把输入变成坐标,
- // 所以解析留在 Dart 侧;非数字就抛中文异常。
- final labels = <String>[];
- final values = <double>[];
- 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 <ToolOutput>[
- ToolChart(
- _chartTypes[_type],
- // fl_chart 需要一个确定高度,否则在无界的高度里会报错。
- SizedBox(height: 320, child: chart),
- ),
- ];
- },
- );
- /// 坐标轴的"好看"上限:最大值再留 20% 余量;全零或全负时给个 1,免得轴退化。
- static double _niceMax(List<double> 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<String> 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<double> values, List<String> 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: <BarChartGroupData>[
- 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<double> values, List<String> 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>[
- LineChartBarData(
- spots: <FlSpot>[
- 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<double> values) {
- var total = 0.0;
- for (final v in values) {
- total += v;
- }
- return PieChart(
- PieChartData(
- sectionsSpace: 4,
- centerSpaceRadius: 40,
- sections: <PieChartSectionData>[
- 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<double> values, List<String> 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>[
- RadarDataSet(
- dataEntries: <RadarEntry>[
- 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,
- );
- }
- }
|