|
@@ -1,103 +1,149 @@
|
|
|
// tools/function_plotter.dart
|
|
// tools/function_plotter.dart
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
-import 'package:math_expressions/math_expressions.dart';
|
|
|
|
|
-import 'package:fl_chart/fl_chart.dart';
|
|
|
|
|
|
|
+import 'package:provider/provider.dart';
|
|
|
|
|
|
|
|
|
|
+import '../curve_plot.dart';
|
|
|
|
|
+import '../expr_syntax.dart';
|
|
|
|
|
+import '../symja.dart';
|
|
|
|
|
+
|
|
|
|
|
+/// 函数图像绘制:把一个或多个 y = f(x) 交给 Symja 采样,再用项目自己的
|
|
|
|
|
+/// [CurvePlot] 画出来。
|
|
|
|
|
+///
|
|
|
|
|
+/// 为什么改成按钮触发的异步绘制:Symja 的求值都跑在原生侧的专用线程上,只能
|
|
|
|
|
+/// 异步拿结果;旧的 `math_expressions` 是同步的,才能每次 build 都重算一遍。
|
|
|
|
|
+/// 现在按下「绘制」采样一次,采样期间禁用按钮并显示忙碌状态。
|
|
|
class FunctionPlotterPage extends StatefulWidget {
|
|
class FunctionPlotterPage extends StatefulWidget {
|
|
|
|
|
+ const FunctionPlotterPage({super.key});
|
|
|
|
|
+
|
|
|
@override
|
|
@override
|
|
|
- _FunctionPlotterPageState createState() => _FunctionPlotterPageState();
|
|
|
|
|
|
|
+ State<FunctionPlotterPage> createState() => _FunctionPlotterPageState();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
|
|
class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
|
|
|
- final List<TextEditingController> _functionControllers = [
|
|
|
|
|
- TextEditingController(text: 'x^2'),
|
|
|
|
|
- ];
|
|
|
|
|
- final TextEditingController _xMinController = TextEditingController(text: '-5');
|
|
|
|
|
- final TextEditingController _xMaxController = TextEditingController(text: '5');
|
|
|
|
|
- final TextEditingController _yMinController = TextEditingController(text: '-10');
|
|
|
|
|
- final TextEditingController _yMaxController = TextEditingController(text: '10');
|
|
|
|
|
-
|
|
|
|
|
- List<Color> _functionColors = [
|
|
|
|
|
|
|
+ static const int _maxCurves = 5;
|
|
|
|
|
+
|
|
|
|
|
+ /// 每条曲线的固定颜色,下标与输入框一一对应(沿用旧版配色)。
|
|
|
|
|
+ static const List<Color> _curveColors = <Color>[
|
|
|
Colors.blue,
|
|
Colors.blue,
|
|
|
Colors.red,
|
|
Colors.red,
|
|
|
Colors.green,
|
|
Colors.green,
|
|
|
Colors.orange,
|
|
Colors.orange,
|
|
|
Colors.purple,
|
|
Colors.purple,
|
|
|
];
|
|
];
|
|
|
-
|
|
|
|
|
- List<String> _functionErrors = [];
|
|
|
|
|
|
|
+
|
|
|
|
|
+ final List<TextEditingController> _exprs = <TextEditingController>[
|
|
|
|
|
+ TextEditingController(text: 'x^2'),
|
|
|
|
|
+ ];
|
|
|
|
|
+ final TextEditingController _xMin = TextEditingController(text: '-5');
|
|
|
|
|
+ final TextEditingController _xMax = TextEditingController(text: '5');
|
|
|
|
|
+ final TextEditingController _yMin = TextEditingController(text: '-10');
|
|
|
|
|
+ final TextEditingController _yMax = TextEditingController(text: '10');
|
|
|
|
|
+
|
|
|
|
|
+ List<CurveSeries> _series = const <CurveSeries>[];
|
|
|
|
|
+
|
|
|
|
|
+ /// 上一次绘制实际使用的 y 范围(没绘制过就是 null,交给 CurvePlot 自动定界)。
|
|
|
|
|
+ double? _plotYMin;
|
|
|
|
|
+ double? _plotYMax;
|
|
|
|
|
+
|
|
|
|
|
+ /// 每个输入框的错误信息,与 [_exprs] 下标对齐;null 表示该项正常。
|
|
|
|
|
+ List<String?> _errors = const <String?>[];
|
|
|
|
|
+
|
|
|
|
|
+ /// 坐标范围本身的错误(与某条曲线无关),显示在绘图区。
|
|
|
|
|
+ String? _rangeError;
|
|
|
|
|
+
|
|
|
|
|
+ bool _busy = false;
|
|
|
|
|
+
|
|
|
|
|
+ @override
|
|
|
|
|
+ void initState() {
|
|
|
|
|
+ super.initState();
|
|
|
|
|
+ // 首帧后再绘制:context.read 需要 element 已经挂载到 Provider 之下。
|
|
|
|
|
+ WidgetsBinding.instance.addPostFrameCallback((_) => _plot());
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
@override
|
|
@override
|
|
|
void dispose() {
|
|
void dispose() {
|
|
|
- for (var controller in _functionControllers) {
|
|
|
|
|
- controller.dispose();
|
|
|
|
|
|
|
+ for (final c in _exprs) {
|
|
|
|
|
+ c.dispose();
|
|
|
}
|
|
}
|
|
|
- _xMinController.dispose();
|
|
|
|
|
- _xMaxController.dispose();
|
|
|
|
|
- _yMinController.dispose();
|
|
|
|
|
- _yMaxController.dispose();
|
|
|
|
|
|
|
+ _xMin.dispose();
|
|
|
|
|
+ _xMax.dispose();
|
|
|
|
|
+ _yMin.dispose();
|
|
|
|
|
+ _yMax.dispose();
|
|
|
super.dispose();
|
|
super.dispose();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- void _addFunction() {
|
|
|
|
|
- if (_functionControllers.length < 5) {
|
|
|
|
|
- setState(() {
|
|
|
|
|
- _functionControllers.add(TextEditingController());
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ void _addCurve() {
|
|
|
|
|
+ if (_exprs.length >= _maxCurves) return;
|
|
|
|
|
+ setState(() => _exprs.add(TextEditingController()));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- void _removeFunction(int index) {
|
|
|
|
|
- if (_functionControllers.length > 1) {
|
|
|
|
|
- setState(() {
|
|
|
|
|
- _functionControllers.removeAt(index);
|
|
|
|
|
- if (_functionErrors.length > index) {
|
|
|
|
|
- _functionErrors.removeAt(index);
|
|
|
|
|
- }
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ void _removeCurve(int index) {
|
|
|
|
|
+ if (_exprs.length <= 1) return;
|
|
|
|
|
+ setState(() => _exprs.removeAt(index).dispose());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- List<FlSpot> _evaluateFunction(String function, double xMin, double xMax, int functionIndex) {
|
|
|
|
|
- List<FlSpot> points = [];
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- Parser p = Parser();
|
|
|
|
|
- Expression exp = p.parse(function);
|
|
|
|
|
- ContextModel cm = ContextModel();
|
|
|
|
|
-
|
|
|
|
|
- int numberOfPoints = 200;
|
|
|
|
|
- double step = (xMax - xMin) / numberOfPoints;
|
|
|
|
|
-
|
|
|
|
|
- for (int i = 0; i <= numberOfPoints; i++) {
|
|
|
|
|
- double xValue = xMin + i * step;
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- cm.bindVariable(Variable('x'), Number(xValue));
|
|
|
|
|
- double yValue = exp.evaluate(EvaluationType.REAL, cm);
|
|
|
|
|
-
|
|
|
|
|
- if (yValue.isFinite && yValue.abs() < 1e10) {
|
|
|
|
|
- points.add(FlSpot(xValue, yValue));
|
|
|
|
|
- } else {
|
|
|
|
|
- points.add(FlSpot.nullSpot);
|
|
|
|
|
- }
|
|
|
|
|
- } catch (e) {
|
|
|
|
|
- points.add(FlSpot.nullSpot);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- } catch (e) {
|
|
|
|
|
- if (_functionErrors.length <= functionIndex) {
|
|
|
|
|
- _functionErrors.addAll(List.filled(functionIndex - _functionErrors.length + 1, ''));
|
|
|
|
|
|
|
+ Future<void> _plot() async {
|
|
|
|
|
+ if (_busy) return;
|
|
|
|
|
+
|
|
|
|
|
+ final xMin = double.tryParse(_xMin.text.trim());
|
|
|
|
|
+ final xMax = double.tryParse(_xMax.text.trim());
|
|
|
|
|
+ final yMin = double.tryParse(_yMin.text.trim());
|
|
|
|
|
+ final yMax = double.tryParse(_yMax.text.trim());
|
|
|
|
|
+
|
|
|
|
|
+ if (xMin == null || xMax == null || yMin == null || yMax == null) {
|
|
|
|
|
+ setState(() => _rangeError = '请输入有效的坐标范围');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (xMin >= xMax || yMin >= yMax) {
|
|
|
|
|
+ setState(() => _rangeError = '最小值必须小于最大值');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ final session = context.read<SymjaSession>();
|
|
|
|
|
+ setState(() {
|
|
|
|
|
+ _busy = true;
|
|
|
|
|
+ _rangeError = null;
|
|
|
|
|
+ _plotYMin = yMin;
|
|
|
|
|
+ _plotYMax = yMax;
|
|
|
|
|
+ // 每次重绘都重建,长度与输入框对齐,旧错误自然被清掉。
|
|
|
|
|
+ _errors = List<String?>.filled(_exprs.length, null);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ final result = <CurveSeries>[];
|
|
|
|
|
+ // 逐条采样:一条失败不影响其它条,错误只挂到它自己那一栏。
|
|
|
|
|
+ for (var i = 0; i < _exprs.length; i++) {
|
|
|
|
|
+ final raw = _exprs[i].text.trim();
|
|
|
|
|
+ if (raw.isEmpty) continue;
|
|
|
|
|
+ try {
|
|
|
|
|
+ // 用户可能沿用旧的小写括号语法,先转成 Symja 语法再采样。
|
|
|
|
|
+ final expr = toSymjaSyntax(raw);
|
|
|
|
|
+ final s = await session.sample(
|
|
|
|
|
+ expr,
|
|
|
|
|
+ variable: 'x',
|
|
|
|
|
+ from: xMin,
|
|
|
|
|
+ to: xMax,
|
|
|
|
|
+ count: 240,
|
|
|
|
|
+ );
|
|
|
|
|
+ if (!mounted) return;
|
|
|
|
|
+ result.add(CurveSeries(
|
|
|
|
|
+ label: raw,
|
|
|
|
|
+ color: _curveColors[i],
|
|
|
|
|
+ // 非有限值(极点处的 ComplexInfinity 等)原样保留,
|
|
|
|
|
+ // CurvePlot 会把它们当成抬笔断点,这里不做过滤。
|
|
|
|
|
+ points: <Offset>[
|
|
|
|
|
+ for (var k = 0; k < s.ys.length; k++) Offset(s.xAt(k), s.ys[k]),
|
|
|
|
|
+ ],
|
|
|
|
|
+ ));
|
|
|
|
|
+ } on SymjaException catch (e) {
|
|
|
|
|
+ if (!mounted) return;
|
|
|
|
|
+ _errors[i] = '$e';
|
|
|
}
|
|
}
|
|
|
- _functionErrors[functionIndex] = '函数解析错误: ${e.toString()}';
|
|
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- return points;
|
|
|
|
|
- }
|
|
|
|
|
|
|
|
|
|
- void _plotFunctions() {
|
|
|
|
|
|
|
+ if (!mounted) return;
|
|
|
setState(() {
|
|
setState(() {
|
|
|
- _functionErrors.clear();
|
|
|
|
|
|
|
+ _series = result;
|
|
|
|
|
+ _busy = false;
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -105,30 +151,26 @@ class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
|
|
|
Widget build(BuildContext context) {
|
|
Widget build(BuildContext context) {
|
|
|
return Scaffold(
|
|
return Scaffold(
|
|
|
appBar: AppBar(
|
|
appBar: AppBar(
|
|
|
- title: Text('函数图像绘制', style: TextStyle(color: Colors.black)),
|
|
|
|
|
|
|
+ title: const Text('函数图像绘制', style: TextStyle(color: Colors.black)),
|
|
|
backgroundColor: Colors.white,
|
|
backgroundColor: Colors.white,
|
|
|
elevation: 1,
|
|
elevation: 1,
|
|
|
actions: [
|
|
actions: [
|
|
|
IconButton(
|
|
IconButton(
|
|
|
- icon: Icon(Icons.refresh),
|
|
|
|
|
- onPressed: _plotFunctions,
|
|
|
|
|
|
|
+ icon: const Icon(Icons.refresh),
|
|
|
|
|
+ onPressed: _busy ? null : _plot,
|
|
|
tooltip: '重新绘制',
|
|
tooltip: '重新绘制',
|
|
|
),
|
|
),
|
|
|
],
|
|
],
|
|
|
),
|
|
),
|
|
|
- body: SingleChildScrollView( // 添加外层滚动
|
|
|
|
|
|
|
+ body: SingleChildScrollView(
|
|
|
child: Padding(
|
|
child: Padding(
|
|
|
- padding: EdgeInsets.all(16),
|
|
|
|
|
|
|
+ padding: const EdgeInsets.all(16),
|
|
|
child: Column(
|
|
child: Column(
|
|
|
children: [
|
|
children: [
|
|
|
- // 函数输入区域 - 移除固定高度约束
|
|
|
|
|
- _buildFunctionInputs(),
|
|
|
|
|
-
|
|
|
|
|
- SizedBox(height: 16), // 添加间距
|
|
|
|
|
-
|
|
|
|
|
- // 绘图区域 - 设置固定高度
|
|
|
|
|
- Container(
|
|
|
|
|
- height: MediaQuery.of(context).size.height * 0.5, // 使用屏幕高度的一半
|
|
|
|
|
|
|
+ _buildInputs(),
|
|
|
|
|
+ const SizedBox(height: 16),
|
|
|
|
|
+ SizedBox(
|
|
|
|
|
+ height: MediaQuery.of(context).size.height * 0.5,
|
|
|
child: _buildPlotArea(),
|
|
child: _buildPlotArea(),
|
|
|
),
|
|
),
|
|
|
],
|
|
],
|
|
@@ -138,140 +180,168 @@ class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- Widget _buildFunctionInputs() {
|
|
|
|
|
|
|
+ Widget _buildInputs() {
|
|
|
return Card(
|
|
return Card(
|
|
|
elevation: 4,
|
|
elevation: 4,
|
|
|
child: Padding(
|
|
child: Padding(
|
|
|
- padding: EdgeInsets.all(16),
|
|
|
|
|
|
|
+ padding: const EdgeInsets.all(16),
|
|
|
child: Column(
|
|
child: Column(
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
children: [
|
|
children: [
|
|
|
- Text(
|
|
|
|
|
|
|
+ const Text(
|
|
|
'函数表达式 (使用 x 作为变量)',
|
|
'函数表达式 (使用 x 作为变量)',
|
|
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
|
|
),
|
|
),
|
|
|
- SizedBox(height: 10),
|
|
|
|
|
- Text(
|
|
|
|
|
- '支持: +, -, *, /, ^, sin, cos, tan, ln, sqrt, e',
|
|
|
|
|
|
|
+ const SizedBox(height: 10),
|
|
|
|
|
+ const Text(
|
|
|
|
|
+ '支持: +, -, *, /, ^, sin, cos, tan, ln, sqrt, pi, e',
|
|
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
|
|
),
|
|
),
|
|
|
- SizedBox(height: 10),
|
|
|
|
|
-
|
|
|
|
|
- // 坐标范围设置 - 在小屏幕上改为垂直布局
|
|
|
|
|
|
|
+ const SizedBox(height: 10),
|
|
|
LayoutBuilder(
|
|
LayoutBuilder(
|
|
|
builder: (context, constraints) {
|
|
builder: (context, constraints) {
|
|
|
|
|
+ final fields = <Widget>[
|
|
|
|
|
+ _buildRangeField(_xMin, 'x最小值'),
|
|
|
|
|
+ _buildRangeField(_xMax, 'x最大值'),
|
|
|
|
|
+ _buildRangeField(_yMin, 'y最小值'),
|
|
|
|
|
+ _buildRangeField(_yMax, 'y最大值'),
|
|
|
|
|
+ ];
|
|
|
|
|
+ // 窄屏竖向排,宽屏横向排。
|
|
|
if (constraints.maxWidth < 600) {
|
|
if (constraints.maxWidth < 600) {
|
|
|
- // 小屏幕垂直布局
|
|
|
|
|
return Column(
|
|
return Column(
|
|
|
children: [
|
|
children: [
|
|
|
- _buildRangeTextField(_xMinController, 'x最小值'),
|
|
|
|
|
- SizedBox(height: 10),
|
|
|
|
|
- _buildRangeTextField(_xMaxController, 'x最大值'),
|
|
|
|
|
- SizedBox(height: 10),
|
|
|
|
|
- _buildRangeTextField(_yMinController, 'y最小值'),
|
|
|
|
|
- SizedBox(height: 10),
|
|
|
|
|
- _buildRangeTextField(_yMaxController, 'y最大值'),
|
|
|
|
|
- ],
|
|
|
|
|
- );
|
|
|
|
|
- } else {
|
|
|
|
|
- // 大屏幕水平布局
|
|
|
|
|
- return Row(
|
|
|
|
|
- children: [
|
|
|
|
|
- Expanded(child: _buildRangeTextField(_xMinController, 'x最小值')),
|
|
|
|
|
- SizedBox(width: 10),
|
|
|
|
|
- Expanded(child: _buildRangeTextField(_xMaxController, 'x最大值')),
|
|
|
|
|
- SizedBox(width: 10),
|
|
|
|
|
- Expanded(child: _buildRangeTextField(_yMinController, 'y最小值')),
|
|
|
|
|
- SizedBox(width: 10),
|
|
|
|
|
- Expanded(child: _buildRangeTextField(_yMaxController, 'y最大值')),
|
|
|
|
|
|
|
+ for (var i = 0; i < fields.length; i++) ...[
|
|
|
|
|
+ if (i > 0) const SizedBox(height: 10),
|
|
|
|
|
+ fields[i],
|
|
|
|
|
+ ],
|
|
|
],
|
|
],
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
+ return Row(
|
|
|
|
|
+ children: [
|
|
|
|
|
+ for (var i = 0; i < fields.length; i++) ...[
|
|
|
|
|
+ if (i > 0) const SizedBox(width: 10),
|
|
|
|
|
+ Expanded(child: fields[i]),
|
|
|
|
|
+ ],
|
|
|
|
|
+ ],
|
|
|
|
|
+ );
|
|
|
},
|
|
},
|
|
|
),
|
|
),
|
|
|
- SizedBox(height: 10),
|
|
|
|
|
-
|
|
|
|
|
- // 函数输入列表 - 设置最大高度并允许滚动
|
|
|
|
|
|
|
+ const SizedBox(height: 4),
|
|
|
|
|
+ const Text(
|
|
|
|
|
+ '注: x 范围决定采样区间,y 范围决定显示窗口;图上还能双指缩放、拖动、双击复位。',
|
|
|
|
|
+ style: TextStyle(fontSize: 11, color: Colors.grey),
|
|
|
|
|
+ ),
|
|
|
|
|
+ const SizedBox(height: 10),
|
|
|
Container(
|
|
Container(
|
|
|
- constraints: BoxConstraints(
|
|
|
|
|
- maxHeight: 200, // 限制最大高度
|
|
|
|
|
- ),
|
|
|
|
|
|
|
+ constraints: const BoxConstraints(maxHeight: 240),
|
|
|
child: ListView.builder(
|
|
child: ListView.builder(
|
|
|
shrinkWrap: true,
|
|
shrinkWrap: true,
|
|
|
- itemCount: _functionControllers.length,
|
|
|
|
|
- itemBuilder: (context, index) {
|
|
|
|
|
- return Padding(
|
|
|
|
|
- padding: EdgeInsets.symmetric(vertical: 5),
|
|
|
|
|
- child: Row(
|
|
|
|
|
- children: [
|
|
|
|
|
- Container(
|
|
|
|
|
- width: 20,
|
|
|
|
|
- height: 20,
|
|
|
|
|
- decoration: BoxDecoration(
|
|
|
|
|
- color: _functionColors[index],
|
|
|
|
|
- shape: BoxShape.circle,
|
|
|
|
|
- ),
|
|
|
|
|
- ),
|
|
|
|
|
- SizedBox(width: 10),
|
|
|
|
|
- Expanded(
|
|
|
|
|
- child: TextField(
|
|
|
|
|
- controller: _functionControllers[index],
|
|
|
|
|
- decoration: InputDecoration(
|
|
|
|
|
- hintText: '例如: x^2, sin(x), ln(x)',
|
|
|
|
|
- border: OutlineInputBorder(),
|
|
|
|
|
- errorText: _functionErrors.length > index ? _functionErrors[index] : null,
|
|
|
|
|
- ),
|
|
|
|
|
- onChanged: (value) => _plotFunctions(),
|
|
|
|
|
- ),
|
|
|
|
|
- ),
|
|
|
|
|
- SizedBox(width: 10),
|
|
|
|
|
- if (_functionControllers.length > 1)
|
|
|
|
|
- IconButton(
|
|
|
|
|
- icon: Icon(Icons.remove, color: Colors.red),
|
|
|
|
|
- onPressed: () => _removeFunction(index),
|
|
|
|
|
- ),
|
|
|
|
|
- ],
|
|
|
|
|
- ),
|
|
|
|
|
- );
|
|
|
|
|
- },
|
|
|
|
|
|
|
+ itemCount: _exprs.length,
|
|
|
|
|
+ itemBuilder: _buildExprRow,
|
|
|
),
|
|
),
|
|
|
),
|
|
),
|
|
|
-
|
|
|
|
|
- SizedBox(height: 10),
|
|
|
|
|
-
|
|
|
|
|
- // 添加函数按钮
|
|
|
|
|
- if (_functionControllers.length < 5)
|
|
|
|
|
- Center(
|
|
|
|
|
- child: ElevatedButton.icon(
|
|
|
|
|
- onPressed: () {
|
|
|
|
|
- _addFunction();
|
|
|
|
|
- _plotFunctions();
|
|
|
|
|
- },
|
|
|
|
|
- icon: Icon(Icons.add),
|
|
|
|
|
- label: Text('添加函数'),
|
|
|
|
|
|
|
+ const SizedBox(height: 10),
|
|
|
|
|
+ Row(
|
|
|
|
|
+ children: [
|
|
|
|
|
+ if (_exprs.length < _maxCurves)
|
|
|
|
|
+ OutlinedButton.icon(
|
|
|
|
|
+ onPressed: _busy ? null : _addCurve,
|
|
|
|
|
+ icon: const Icon(Icons.add),
|
|
|
|
|
+ label: const Text('添加函数'),
|
|
|
|
|
+ ),
|
|
|
|
|
+ const Spacer(),
|
|
|
|
|
+ ElevatedButton.icon(
|
|
|
|
|
+ onPressed: _busy ? null : _plot,
|
|
|
|
|
+ icon: _busy
|
|
|
|
|
+ ? const SizedBox(
|
|
|
|
|
+ width: 16,
|
|
|
|
|
+ height: 16,
|
|
|
|
|
+ child: CircularProgressIndicator(strokeWidth: 2),
|
|
|
|
|
+ )
|
|
|
|
|
+ : const Icon(Icons.show_chart),
|
|
|
|
|
+ label: Text(_busy ? '绘制中…' : '绘制图像'),
|
|
|
style: ElevatedButton.styleFrom(
|
|
style: ElevatedButton.styleFrom(
|
|
|
backgroundColor: Colors.blue,
|
|
backgroundColor: Colors.blue,
|
|
|
foregroundColor: Colors.white,
|
|
foregroundColor: Colors.white,
|
|
|
),
|
|
),
|
|
|
),
|
|
),
|
|
|
- ),
|
|
|
|
|
|
|
+ ],
|
|
|
|
|
+ ),
|
|
|
],
|
|
],
|
|
|
),
|
|
),
|
|
|
),
|
|
),
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 辅助方法:构建范围输入框
|
|
|
|
|
- Widget _buildRangeTextField(TextEditingController controller, String label) {
|
|
|
|
|
|
|
+ Widget _buildExprRow(BuildContext context, int index) {
|
|
|
|
|
+ final raw = _exprs[index].text.trim();
|
|
|
|
|
+ // 只有在语法真的被改写时才提示,避免对已经正确的输入刷屏。
|
|
|
|
|
+ final rewrite = raw.isEmpty ? null : symjaRewriteOf(raw);
|
|
|
|
|
+ return Padding(
|
|
|
|
|
+ padding: const EdgeInsets.symmetric(vertical: 5),
|
|
|
|
|
+ child: Row(
|
|
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
+ children: [
|
|
|
|
|
+ Container(
|
|
|
|
|
+ width: 20,
|
|
|
|
|
+ height: 20,
|
|
|
|
|
+ margin: const EdgeInsets.only(top: 16),
|
|
|
|
|
+ decoration: BoxDecoration(
|
|
|
|
|
+ color: _curveColors[index],
|
|
|
|
|
+ shape: BoxShape.circle,
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ const SizedBox(width: 10),
|
|
|
|
|
+ Expanded(
|
|
|
|
|
+ child: Column(
|
|
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
+ children: [
|
|
|
|
|
+ TextField(
|
|
|
|
|
+ controller: _exprs[index],
|
|
|
|
|
+ // 输入时重建,让下面的语法转换提示实时更新。
|
|
|
|
|
+ onChanged: (_) => setState(() {}),
|
|
|
|
|
+ decoration: InputDecoration(
|
|
|
|
|
+ hintText: '例如: x^2, sin(x), Sin[x]',
|
|
|
|
|
+ border: const OutlineInputBorder(),
|
|
|
|
|
+ errorText: index < _errors.length ? _errors[index] : null,
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ if (rewrite != null)
|
|
|
|
|
+ Padding(
|
|
|
|
|
+ padding: const EdgeInsets.only(top: 4, left: 4),
|
|
|
|
|
+ child: Text(
|
|
|
|
|
+ '已按 Symja 语法解释为: $rewrite',
|
|
|
|
|
+ style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ],
|
|
|
|
|
+ ),
|
|
|
|
|
+ ),
|
|
|
|
|
+ const SizedBox(width: 10),
|
|
|
|
|
+ if (_exprs.length > 1)
|
|
|
|
|
+ IconButton(
|
|
|
|
|
+ icon: const Icon(Icons.remove, color: Colors.red),
|
|
|
|
|
+ tooltip: '删除该函数',
|
|
|
|
|
+ onPressed: () => _removeCurve(index),
|
|
|
|
|
+ ),
|
|
|
|
|
+ ],
|
|
|
|
|
+ ),
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Widget _buildRangeField(TextEditingController controller, String label) {
|
|
|
return TextField(
|
|
return TextField(
|
|
|
controller: controller,
|
|
controller: controller,
|
|
|
decoration: InputDecoration(
|
|
decoration: InputDecoration(
|
|
|
labelText: label,
|
|
labelText: label,
|
|
|
- border: OutlineInputBorder(),
|
|
|
|
|
- contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
|
|
|
|
|
+ border: const OutlineInputBorder(),
|
|
|
|
|
+ contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
|
|
|
+ ),
|
|
|
|
|
+ keyboardType: TextInputType.numberWithOptions(
|
|
|
|
|
+ signed: true,
|
|
|
|
|
+ decimal: true,
|
|
|
),
|
|
),
|
|
|
- keyboardType: TextInputType.numberWithOptions(signed: true, decimal: true),
|
|
|
|
|
- onChanged: (value) => _plotFunctions(),
|
|
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -279,17 +349,15 @@ class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
|
|
|
return Card(
|
|
return Card(
|
|
|
elevation: 4,
|
|
elevation: 4,
|
|
|
child: Padding(
|
|
child: Padding(
|
|
|
- padding: EdgeInsets.all(16),
|
|
|
|
|
|
|
+ padding: const EdgeInsets.all(16),
|
|
|
child: Column(
|
|
child: Column(
|
|
|
children: [
|
|
children: [
|
|
|
- Text(
|
|
|
|
|
|
|
+ const Text(
|
|
|
'函数图像',
|
|
'函数图像',
|
|
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
|
|
),
|
|
),
|
|
|
- SizedBox(height: 10),
|
|
|
|
|
- Expanded(
|
|
|
|
|
- child: _buildPlot(),
|
|
|
|
|
- ),
|
|
|
|
|
|
|
+ const SizedBox(height: 10),
|
|
|
|
|
+ Expanded(child: _buildPlot()),
|
|
|
],
|
|
],
|
|
|
),
|
|
),
|
|
|
),
|
|
),
|
|
@@ -297,153 +365,30 @@ class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
Widget _buildPlot() {
|
|
Widget _buildPlot() {
|
|
|
- double xMin, xMax, yMin, yMax;
|
|
|
|
|
-
|
|
|
|
|
- try {
|
|
|
|
|
- xMin = double.parse(_xMinController.text);
|
|
|
|
|
- xMax = double.parse(_xMaxController.text);
|
|
|
|
|
- yMin = double.parse(_yMinController.text);
|
|
|
|
|
- yMax = double.parse(_yMaxController.text);
|
|
|
|
|
- } catch (e) {
|
|
|
|
|
- return Center(
|
|
|
|
|
- child: Text('请输入有效的坐标范围', style: TextStyle(color: Colors.red)),
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ if (_busy) {
|
|
|
|
|
+ return const Center(child: CircularProgressIndicator());
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- if (xMin >= xMax || yMin >= yMax) {
|
|
|
|
|
|
|
+ if (_rangeError != null) {
|
|
|
return Center(
|
|
return Center(
|
|
|
- child: Text('最小值必须小于最大值', style: TextStyle(color: Colors.red)),
|
|
|
|
|
|
|
+ child: Text(_rangeError!, style: const TextStyle(color: Colors.red)),
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- List<LineChartBarData> lineBars = [];
|
|
|
|
|
-
|
|
|
|
|
- for (int i = 0; i < _functionControllers.length; i++) {
|
|
|
|
|
- String function = _functionControllers[i].text.trim();
|
|
|
|
|
- if (function.isEmpty) continue;
|
|
|
|
|
-
|
|
|
|
|
- List<FlSpot> spots = _evaluateFunction(function, xMin, xMax, i);
|
|
|
|
|
-
|
|
|
|
|
- if (spots.isNotEmpty) {
|
|
|
|
|
- lineBars.add(
|
|
|
|
|
- LineChartBarData(
|
|
|
|
|
- spots: spots,
|
|
|
|
|
- isCurved: true,
|
|
|
|
|
- color: _functionColors[i],
|
|
|
|
|
- barWidth: 2,
|
|
|
|
|
- isStrokeCapRound: true,
|
|
|
|
|
- dotData: FlDotData(show: false),
|
|
|
|
|
- belowBarData: BarAreaData(show: false),
|
|
|
|
|
- ),
|
|
|
|
|
- );
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if (lineBars.isEmpty) {
|
|
|
|
|
|
|
+ if (_series.isEmpty) {
|
|
|
return Center(
|
|
return Center(
|
|
|
child: Column(
|
|
child: Column(
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
- children: [
|
|
|
|
|
|
|
+ children: const [
|
|
|
Icon(Icons.functions, size: 50, color: Colors.grey),
|
|
Icon(Icons.functions, size: 50, color: Colors.grey),
|
|
|
SizedBox(height: 10),
|
|
SizedBox(height: 10),
|
|
|
Text('暂无函数图像可显示', style: TextStyle(color: Colors.grey)),
|
|
Text('暂无函数图像可显示', style: TextStyle(color: Colors.grey)),
|
|
|
- Text('请输入有效的函数表达式', style: TextStyle(color: Colors.grey, fontSize: 12)),
|
|
|
|
|
|
|
+ Text(
|
|
|
|
|
+ '请输入函数表达式后点击「绘制图像」',
|
|
|
|
|
+ style: TextStyle(color: Colors.grey, fontSize: 12),
|
|
|
|
|
+ ),
|
|
|
],
|
|
],
|
|
|
),
|
|
),
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- return LineChart(
|
|
|
|
|
- LineChartData(
|
|
|
|
|
- clipData: FlClipData.all(),
|
|
|
|
|
- gridData: FlGridData(
|
|
|
|
|
- show: true,
|
|
|
|
|
- drawVerticalLine: true,
|
|
|
|
|
- drawHorizontalLine: true,
|
|
|
|
|
- getDrawingHorizontalLine: (value) {
|
|
|
|
|
- return FlLine(
|
|
|
|
|
- color: Colors.grey.withOpacity(0.3),
|
|
|
|
|
- strokeWidth: 1,
|
|
|
|
|
- );
|
|
|
|
|
- },
|
|
|
|
|
- getDrawingVerticalLine: (value) {
|
|
|
|
|
- return FlLine(
|
|
|
|
|
- color: Colors.grey.withOpacity(0.3),
|
|
|
|
|
- strokeWidth: 1,
|
|
|
|
|
- );
|
|
|
|
|
- },
|
|
|
|
|
- ),
|
|
|
|
|
- titlesData: FlTitlesData(
|
|
|
|
|
- show: true,
|
|
|
|
|
- bottomTitles: AxisTitles(
|
|
|
|
|
- sideTitles: SideTitles(
|
|
|
|
|
- showTitles: true,
|
|
|
|
|
- reservedSize: 30,
|
|
|
|
|
- interval: _calculateInterval(xMin, xMax),
|
|
|
|
|
- getTitlesWidget: (value, meta) {
|
|
|
|
|
- return Text(
|
|
|
|
|
- value.toStringAsFixed(1),
|
|
|
|
|
- style: TextStyle(fontSize: 10, color: Colors.black),
|
|
|
|
|
- );
|
|
|
|
|
- },
|
|
|
|
|
- ),
|
|
|
|
|
- ),
|
|
|
|
|
- leftTitles: AxisTitles(
|
|
|
|
|
- sideTitles: SideTitles(
|
|
|
|
|
- showTitles: true,
|
|
|
|
|
- reservedSize: 40,
|
|
|
|
|
- interval: _calculateInterval(yMin, yMax),
|
|
|
|
|
- getTitlesWidget: (value, meta) {
|
|
|
|
|
- return Text(
|
|
|
|
|
- value.toStringAsFixed(1),
|
|
|
|
|
- style: TextStyle(fontSize: 10, color: Colors.black),
|
|
|
|
|
- );
|
|
|
|
|
- },
|
|
|
|
|
- ),
|
|
|
|
|
- ),
|
|
|
|
|
- rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
|
|
|
- topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
|
|
|
- ),
|
|
|
|
|
- borderData: FlBorderData(
|
|
|
|
|
- show: true,
|
|
|
|
|
- border: Border.all(color: Colors.black, width: 2),
|
|
|
|
|
- ),
|
|
|
|
|
- minX: xMin,
|
|
|
|
|
- maxX: xMax,
|
|
|
|
|
- minY: yMin,
|
|
|
|
|
- maxY: yMax,
|
|
|
|
|
- lineBarsData: lineBars,
|
|
|
|
|
- lineTouchData: LineTouchData(enabled: false),
|
|
|
|
|
- extraLinesData: ExtraLinesData(
|
|
|
|
|
- horizontalLines: [
|
|
|
|
|
- HorizontalLine(
|
|
|
|
|
- y: 0,
|
|
|
|
|
- color: Colors.black,
|
|
|
|
|
- strokeWidth: 1,
|
|
|
|
|
- ),
|
|
|
|
|
- ],
|
|
|
|
|
- verticalLines: [
|
|
|
|
|
- VerticalLine(
|
|
|
|
|
- x: 0,
|
|
|
|
|
- color: Colors.black,
|
|
|
|
|
- strokeWidth: 1,
|
|
|
|
|
- ),
|
|
|
|
|
- ],
|
|
|
|
|
- ),
|
|
|
|
|
- ),
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ return CurvePlot(series: _series, yMin: _plotYMin, yMax: _plotYMax);
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- double _calculateInterval(double min, double max) {
|
|
|
|
|
- double range = max - min;
|
|
|
|
|
- if (range <= 0) return 1.0;
|
|
|
|
|
-
|
|
|
|
|
- if (range <= 2) return 0.5;
|
|
|
|
|
- if (range <= 5) return 1.0;
|
|
|
|
|
- if (range <= 10) return 2.0;
|
|
|
|
|
- if (range <= 20) return 5.0;
|
|
|
|
|
- if (range <= 50) return 10.0;
|
|
|
|
|
- if (range <= 100) return 20.0;
|
|
|
|
|
- return 50.0;
|
|
|
|
|
- }
|
|
|
|
|
-}
|
|
|
|
|
|
|
+}
|