curve_plot.dart 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. import 'dart:math' as math;
  2. import 'package:flutter/material.dart';
  3. /// 一条曲线。点用数据坐标;任一分量为 NaN 表示断点(画笔抬起)。
  4. ///
  5. /// 用 (x, y) 点对而不是「y 数组 + x 范围」,是为了同一个组件能画
  6. /// 函数曲线、参数方程、极坐标 —— 后两者的 x 和 y 都在变。
  7. @immutable
  8. class CurveSeries {
  9. final String label;
  10. final List<Offset> points;
  11. final Color color;
  12. const CurveSeries({
  13. required this.label,
  14. required this.points,
  15. required this.color,
  16. });
  17. }
  18. /// 曲线绘制组件:自动定界、网格、刻度、多曲线图例、双指缩放/拖动、双击复位。
  19. ///
  20. /// 放在 lib/ 顶层而不是 tools/ 下:控制台的 `plot` 命令和计算器都要用它,
  21. /// mathtool2 搬过来的「函数图像绘制 / 参数方程绘图」也换成它,
  22. /// 避免维护两套绘图。
  23. class CurvePlot extends StatefulWidget {
  24. const CurvePlot({super.key, required this.series});
  25. final List<CurveSeries> series;
  26. @override
  27. State<CurvePlot> createState() => _CurvePlotState();
  28. }
  29. class _CurvePlotState extends State<CurvePlot> {
  30. double _zoom = 1;
  31. Offset _pan = Offset.zero;
  32. double _zoomStart = 1;
  33. Offset _panStart = Offset.zero;
  34. void _reset() => setState(() {
  35. _zoom = 1;
  36. _pan = Offset.zero;
  37. });
  38. @override
  39. Widget build(BuildContext context) {
  40. return GestureDetector(
  41. onDoubleTap: _reset,
  42. onScaleStart: (_) {
  43. _zoomStart = _zoom;
  44. _panStart = _pan;
  45. },
  46. onScaleUpdate: (d) => setState(() {
  47. _zoom = (_zoomStart * d.scale).clamp(0.2, 50.0);
  48. _pan = _panStart + d.focalPointDelta;
  49. }),
  50. child: ClipRect(
  51. child: CustomPaint(
  52. painter: _CurvePainter(widget.series, _zoom, _pan),
  53. child: const SizedBox.expand(),
  54. ),
  55. ),
  56. );
  57. }
  58. }
  59. class _CurvePainter extends CustomPainter {
  60. _CurvePainter(this.series, this.zoom, this.pan);
  61. final List<CurveSeries> series;
  62. final double zoom;
  63. final Offset pan;
  64. static const _padLeft = 46.0;
  65. static const _padRight = 12.0;
  66. static const _padTop = 12.0;
  67. static const _padBottom = 26.0;
  68. @override
  69. void paint(Canvas canvas, Size size) {
  70. canvas.drawRect(
  71. Offset.zero & size,
  72. Paint()..color = const Color(0xFFFAFAFC),
  73. );
  74. // 数据边界
  75. double? x0, x1, y0, y1;
  76. for (final s in series) {
  77. for (final p in s.points) {
  78. if (!p.dx.isFinite || !p.dy.isFinite) continue;
  79. x0 = (x0 == null) ? p.dx : math.min(x0, p.dx);
  80. x1 = (x1 == null) ? p.dx : math.max(x1, p.dx);
  81. y0 = (y0 == null) ? p.dy : math.min(y0, p.dy);
  82. y1 = (y1 == null) ? p.dy : math.max(y1, p.dy);
  83. }
  84. }
  85. if (x0 == null || y0 == null || x1 == null || y1 == null) {
  86. _text(canvas, '没有可绘制的有限点', Offset(size.width / 2 - 60, size.height / 2),
  87. const Color(0xFF9E9E9E), 13);
  88. return;
  89. }
  90. if (x1 - x0 < 1e-12) {
  91. x0 -= 1;
  92. x1 += 1;
  93. }
  94. if (y1 - y0 < 1e-12) {
  95. y0 -= 1;
  96. y1 += 1;
  97. }
  98. final plot = Rect.fromLTRB(
  99. _padLeft,
  100. _padTop,
  101. size.width - _padRight,
  102. size.height - _padBottom,
  103. );
  104. // 数据 -> 屏幕
  105. double sx(double x) {
  106. final t = (x - x0!) / (x1! - x0);
  107. return plot.left + (plot.width / 2) + (t * plot.width - plot.width / 2) * zoom + pan.dx;
  108. }
  109. double sy(double y) {
  110. final t = (y - y0!) / (y1! - y0);
  111. return plot.bottom - (plot.height / 2) -
  112. (t * plot.height - plot.height / 2) * zoom +
  113. pan.dy;
  114. }
  115. // 内边距裁掉越界部分,避免曲线画到坐标轴外面
  116. canvas.save();
  117. canvas.clipRect(plot);
  118. _grid(canvas, plot, x0, x1, y0, y1, sx, sy);
  119. canvas.restore();
  120. // 坐标轴(可能落在可视区外,用画布坐标直接画)
  121. final axes = Paint()
  122. ..color = const Color(0xFF9E9E9E)
  123. ..strokeWidth = 1.2;
  124. if (y0 <= 0 && y1 >= 0) {
  125. final y = sy(0);
  126. if (y >= plot.top && y <= plot.bottom) {
  127. canvas.drawLine(Offset(plot.left, y), Offset(plot.right, y), axes);
  128. }
  129. }
  130. if (x0 <= 0 && x1 >= 0) {
  131. final x = sx(0);
  132. if (x >= plot.left && x <= plot.right) {
  133. canvas.drawLine(Offset(x, plot.top), Offset(x, plot.bottom), axes);
  134. }
  135. }
  136. canvas.save();
  137. canvas.clipRect(plot);
  138. for (final s in series) {
  139. final paint = Paint()
  140. ..color = s.color
  141. ..strokeWidth = 2
  142. ..style = PaintingStyle.stroke
  143. ..strokeJoin = StrokeJoin.round;
  144. final path = Path();
  145. var pen = false;
  146. for (final p in s.points) {
  147. if (!p.dx.isFinite || !p.dy.isFinite) {
  148. pen = false; // 断点
  149. continue;
  150. }
  151. final o = Offset(sx(p.dx), sy(p.dy));
  152. if (!pen) {
  153. path.moveTo(o.dx, o.dy);
  154. pen = true;
  155. } else {
  156. path.lineTo(o.dx, o.dy);
  157. }
  158. }
  159. canvas.drawPath(path, paint);
  160. }
  161. canvas.restore();
  162. _axisLabels(canvas, plot, x0, x1, y0, y1, sx, sy);
  163. if (series.length > 1) _legend(canvas, plot);
  164. }
  165. void _grid(Canvas canvas, Rect plot, double x0, double x1, double y0, double y1,
  166. double Function(double) sx, double Function(double) sy) {
  167. final paint = Paint()
  168. ..color = const Color(0xFFE8E8EE)
  169. ..strokeWidth = 1;
  170. final stepX = _niceStep(x1 - x0);
  171. for (var x = (x0 / stepX).ceil() * stepX; x <= x1; x += stepX) {
  172. final px = sx(x);
  173. if (px >= plot.left && px <= plot.right) {
  174. canvas.drawLine(Offset(px, plot.top), Offset(px, plot.bottom), paint);
  175. }
  176. }
  177. final stepY = _niceStep(y1 - y0);
  178. for (var y = (y0 / stepY).ceil() * stepY; y <= y1; y += stepY) {
  179. final py = sy(y);
  180. if (py >= plot.top && py <= plot.bottom) {
  181. canvas.drawLine(Offset(plot.left, py), Offset(plot.right, py), paint);
  182. }
  183. }
  184. }
  185. void _axisLabels(Canvas canvas, Rect plot, double x0, double x1, double y0,
  186. double y1, double Function(double) sx, double Function(double) sy) {
  187. const c = Color(0xFF757575);
  188. final stepX = _niceStep(x1 - x0);
  189. for (var x = (x0 / stepX).ceil() * stepX; x <= x1; x += stepX) {
  190. final px = sx(x);
  191. if (px < plot.left || px > plot.right) continue;
  192. _text(canvas, _fmt(x), Offset(px - 12, plot.bottom + 6), c, 10);
  193. }
  194. final stepY = _niceStep(y1 - y0);
  195. for (var y = (y0 / stepY).ceil() * stepY; y <= y1; y += stepY) {
  196. final py = sy(y);
  197. if (py < plot.top || py > plot.bottom) continue;
  198. _text(canvas, _fmt(y), Offset(2, py - 6), c, 10);
  199. }
  200. }
  201. void _legend(Canvas canvas, Rect plot) {
  202. var y = plot.top + 4;
  203. for (final s in series) {
  204. canvas.drawRect(
  205. Rect.fromLTWH(plot.right - 108, y + 3, 14, 3),
  206. Paint()..color = s.color,
  207. );
  208. _text(canvas, s.label, Offset(plot.right - 88, y - 3),
  209. const Color(0xFF616161), 10);
  210. y += 14;
  211. }
  212. }
  213. void _text(Canvas canvas, String s, Offset at, Color color, double size) {
  214. final tp = TextPainter(
  215. text: TextSpan(
  216. text: s,
  217. style: TextStyle(color: color, fontSize: size),
  218. ),
  219. textDirection: TextDirection.ltr,
  220. )..layout();
  221. tp.paint(canvas, at);
  222. }
  223. /// 取 1/2/5 这种"好看"的刻度间隔。
  224. static double _niceStep(double span) {
  225. if (span <= 0 || !span.isFinite) return 1;
  226. final raw = span / 6;
  227. final mag = math.pow(10, (math.log(raw) / math.ln10).floor()).toDouble();
  228. for (final m in const [1.0, 2.0, 5.0, 10.0]) {
  229. if (raw <= m * mag) return m * mag;
  230. }
  231. return 10 * mag;
  232. }
  233. static String _fmt(double v) {
  234. if (v == 0) return '0';
  235. final a = v.abs();
  236. if (a >= 1e5 || a < 1e-3) return v.toStringAsExponential(1);
  237. final s = v.toStringAsFixed(3);
  238. return s.replaceFirst(RegExp(r'\.?0+$'), '');
  239. }
  240. @override
  241. bool shouldRepaint(covariant _CurvePainter old) =>
  242. old.series != series || old.zoom != zoom || old.pan != pan;
  243. }