curve_plot.dart 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  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({
  25. super.key,
  26. required this.series,
  27. this.yMin,
  28. this.yMax,
  29. });
  30. final List<CurveSeries> series;
  31. /// 显式指定 y 轴范围。不给就按数据自动定界。
  32. ///
  33. /// 为什么需要:自动定界对有极点的函数很难看(`Tan[x]` 会被几个巨大的有限值
  34. /// 撑爆整幅图)。`函数图像绘制` 工具原本就能填 y 范围,这个参数把它接住。
  35. final double? yMin;
  36. final double? yMax;
  37. @override
  38. State<CurvePlot> createState() => _CurvePlotState();
  39. }
  40. class _CurvePlotState extends State<CurvePlot> {
  41. double _zoom = 1;
  42. Offset _pan = Offset.zero;
  43. double _zoomStart = 1;
  44. Offset _panStart = Offset.zero;
  45. void _reset() => setState(() {
  46. _zoom = 1;
  47. _pan = Offset.zero;
  48. });
  49. @override
  50. Widget build(BuildContext context) {
  51. return GestureDetector(
  52. onDoubleTap: _reset,
  53. onScaleStart: (_) {
  54. _zoomStart = _zoom;
  55. _panStart = _pan;
  56. },
  57. onScaleUpdate: (d) => setState(() {
  58. _zoom = (_zoomStart * d.scale).clamp(0.2, 50.0);
  59. _pan = _panStart + d.focalPointDelta;
  60. }),
  61. child: ClipRect(
  62. child: CustomPaint(
  63. painter: _CurvePainter(
  64. widget.series, _zoom, _pan,
  65. yMin: widget.yMin, yMax: widget.yMax,
  66. ),
  67. child: const SizedBox.expand(),
  68. ),
  69. ),
  70. );
  71. }
  72. }
  73. class _CurvePainter extends CustomPainter {
  74. _CurvePainter(this.series, this.zoom, this.pan, {this.yMin, this.yMax});
  75. final List<CurveSeries> series;
  76. final double zoom;
  77. final Offset pan;
  78. final double? yMin;
  79. final double? yMax;
  80. static const _padLeft = 46.0;
  81. static const _padRight = 12.0;
  82. static const _padTop = 12.0;
  83. static const _padBottom = 26.0;
  84. @override
  85. void paint(Canvas canvas, Size size) {
  86. canvas.drawRect(
  87. Offset.zero & size,
  88. Paint()..color = const Color(0xFFFAFAFC),
  89. );
  90. // 数据边界
  91. double? x0, x1, y0, y1;
  92. for (final s in series) {
  93. for (final p in s.points) {
  94. if (!p.dx.isFinite || !p.dy.isFinite) continue;
  95. x0 = (x0 == null) ? p.dx : math.min(x0, p.dx);
  96. x1 = (x1 == null) ? p.dx : math.max(x1, p.dx);
  97. y0 = (y0 == null) ? p.dy : math.min(y0, p.dy);
  98. y1 = (y1 == null) ? p.dy : math.max(y1, p.dy);
  99. }
  100. }
  101. if (x0 == null || y0 == null || x1 == null || y1 == null) {
  102. _text(canvas, '没有可绘制的有限点', Offset(size.width / 2 - 60, size.height / 2),
  103. const Color(0xFF9E9E9E), 13);
  104. return;
  105. }
  106. if (x1 - x0 < 1e-12) {
  107. x0 -= 1;
  108. x1 += 1;
  109. }
  110. // 显式给了 y 范围就用它 —— 自动定界碰上带极点的函数会被几个大值撑爆(Tan[x] 那种)
  111. final fixedYMin = yMin;
  112. final fixedYMax = yMax;
  113. if (fixedYMin != null) y0 = fixedYMin;
  114. if (fixedYMax != null) y1 = fixedYMax;
  115. if (y1 - y0 < 1e-12) {
  116. y0 -= 1;
  117. y1 += 1;
  118. }
  119. final plot = Rect.fromLTRB(
  120. _padLeft,
  121. _padTop,
  122. size.width - _padRight,
  123. size.height - _padBottom,
  124. );
  125. // 数据 -> 屏幕
  126. double sx(double x) {
  127. final t = (x - x0!) / (x1! - x0);
  128. return plot.left + (plot.width / 2) + (t * plot.width - plot.width / 2) * zoom + pan.dx;
  129. }
  130. double sy(double y) {
  131. final t = (y - y0!) / (y1! - y0);
  132. return plot.bottom - (plot.height / 2) -
  133. (t * plot.height - plot.height / 2) * zoom +
  134. pan.dy;
  135. }
  136. // 内边距裁掉越界部分,避免曲线画到坐标轴外面
  137. canvas.save();
  138. canvas.clipRect(plot);
  139. _grid(canvas, plot, x0, x1, y0, y1, sx, sy);
  140. canvas.restore();
  141. // 坐标轴(可能落在可视区外,用画布坐标直接画)
  142. final axes = Paint()
  143. ..color = const Color(0xFF9E9E9E)
  144. ..strokeWidth = 1.2;
  145. if (y0 <= 0 && y1 >= 0) {
  146. final y = sy(0);
  147. if (y >= plot.top && y <= plot.bottom) {
  148. canvas.drawLine(Offset(plot.left, y), Offset(plot.right, y), axes);
  149. }
  150. }
  151. if (x0 <= 0 && x1 >= 0) {
  152. final x = sx(0);
  153. if (x >= plot.left && x <= plot.right) {
  154. canvas.drawLine(Offset(x, plot.top), Offset(x, plot.bottom), axes);
  155. }
  156. }
  157. canvas.save();
  158. canvas.clipRect(plot);
  159. for (final s in series) {
  160. final paint = Paint()
  161. ..color = s.color
  162. ..strokeWidth = 2
  163. ..style = PaintingStyle.stroke
  164. ..strokeJoin = StrokeJoin.round;
  165. final path = Path();
  166. var pen = false;
  167. for (final p in s.points) {
  168. if (!p.dx.isFinite || !p.dy.isFinite) {
  169. pen = false; // 断点
  170. continue;
  171. }
  172. final o = Offset(sx(p.dx), sy(p.dy));
  173. if (!pen) {
  174. path.moveTo(o.dx, o.dy);
  175. pen = true;
  176. } else {
  177. path.lineTo(o.dx, o.dy);
  178. }
  179. }
  180. canvas.drawPath(path, paint);
  181. }
  182. canvas.restore();
  183. _axisLabels(canvas, plot, x0, x1, y0, y1, sx, sy);
  184. if (series.length > 1) _legend(canvas, plot);
  185. }
  186. void _grid(Canvas canvas, Rect plot, double x0, double x1, double y0, double y1,
  187. double Function(double) sx, double Function(double) sy) {
  188. final paint = Paint()
  189. ..color = const Color(0xFFE8E8EE)
  190. ..strokeWidth = 1;
  191. final stepX = _niceStep(x1 - x0);
  192. for (var x = (x0 / stepX).ceil() * stepX; x <= x1; x += stepX) {
  193. final px = sx(x);
  194. if (px >= plot.left && px <= plot.right) {
  195. canvas.drawLine(Offset(px, plot.top), Offset(px, plot.bottom), paint);
  196. }
  197. }
  198. final stepY = _niceStep(y1 - y0);
  199. for (var y = (y0 / stepY).ceil() * stepY; y <= y1; y += stepY) {
  200. final py = sy(y);
  201. if (py >= plot.top && py <= plot.bottom) {
  202. canvas.drawLine(Offset(plot.left, py), Offset(plot.right, py), paint);
  203. }
  204. }
  205. }
  206. void _axisLabels(Canvas canvas, Rect plot, double x0, double x1, double y0,
  207. double y1, double Function(double) sx, double Function(double) sy) {
  208. const c = Color(0xFF757575);
  209. final stepX = _niceStep(x1 - x0);
  210. for (var x = (x0 / stepX).ceil() * stepX; x <= x1; x += stepX) {
  211. final px = sx(x);
  212. if (px < plot.left || px > plot.right) continue;
  213. _text(canvas, _fmt(x), Offset(px - 12, plot.bottom + 6), c, 10);
  214. }
  215. final stepY = _niceStep(y1 - y0);
  216. for (var y = (y0 / stepY).ceil() * stepY; y <= y1; y += stepY) {
  217. final py = sy(y);
  218. if (py < plot.top || py > plot.bottom) continue;
  219. _text(canvas, _fmt(y), Offset(2, py - 6), c, 10);
  220. }
  221. }
  222. void _legend(Canvas canvas, Rect plot) {
  223. var y = plot.top + 4;
  224. for (final s in series) {
  225. canvas.drawRect(
  226. Rect.fromLTWH(plot.right - 108, y + 3, 14, 3),
  227. Paint()..color = s.color,
  228. );
  229. _text(canvas, s.label, Offset(plot.right - 88, y - 3),
  230. const Color(0xFF616161), 10);
  231. y += 14;
  232. }
  233. }
  234. void _text(Canvas canvas, String s, Offset at, Color color, double size) {
  235. final tp = TextPainter(
  236. text: TextSpan(
  237. text: s,
  238. style: TextStyle(color: color, fontSize: size),
  239. ),
  240. textDirection: TextDirection.ltr,
  241. )..layout();
  242. tp.paint(canvas, at);
  243. }
  244. /// 取 1/2/5 这种"好看"的刻度间隔。
  245. static double _niceStep(double span) {
  246. if (span <= 0 || !span.isFinite) return 1;
  247. final raw = span / 6;
  248. final mag = math.pow(10, (math.log(raw) / math.ln10).floor()).toDouble();
  249. for (final m in const [1.0, 2.0, 5.0, 10.0]) {
  250. if (raw <= m * mag) return m * mag;
  251. }
  252. return 10 * mag;
  253. }
  254. static String _fmt(double v) {
  255. if (v == 0) return '0';
  256. final a = v.abs();
  257. if (a >= 1e5 || a < 1e-3) return v.toStringAsExponential(1);
  258. final s = v.toStringAsFixed(3);
  259. return s.replaceFirst(RegExp(r'\.?0+$'), '');
  260. }
  261. @override
  262. bool shouldRepaint(covariant _CurvePainter old) =>
  263. old.series != series ||
  264. old.zoom != zoom ||
  265. old.pan != pan ||
  266. old.yMin != yMin ||
  267. old.yMax != yMax;
  268. }