curve_plot.dart 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  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. /// **不用手势操作** —— 缩放和平移改成下面两条滑动条。
  21. /// 图在手机屏幕上很小,双指捏合容易误触,而且缩过头就找不回来了;
  22. /// 滑条能看出当前窗口在哪、也能一键复位。
  23. class CurvePlot extends StatefulWidget {
  24. const CurvePlot({
  25. super.key,
  26. required this.series,
  27. this.yMin,
  28. this.yMax,
  29. this.showControls = true,
  30. });
  31. final List<CurveSeries> series;
  32. /// 纵轴的**初始**窗口(不给就按数据自动定界)。之后由滑动条接管。
  33. final double? yMin;
  34. final double? yMax;
  35. /// 是否显示下面的滑动条。嵌在很小的对话框里时可以关掉。
  36. final bool showControls;
  37. @override
  38. State<CurvePlot> createState() => _CurvePlotState();
  39. }
  40. /// 数据在各轴上的范围。
  41. class _Bounds {
  42. final double min;
  43. final double max;
  44. const _Bounds(this.min, this.max);
  45. double get span => max - min;
  46. }
  47. class _CurvePlotState extends State<CurvePlot> {
  48. /// 当前视图窗口。null = 跟着数据自动定界。
  49. double? _x0, _x1, _y0, _y1;
  50. _Bounds? _dataX;
  51. _Bounds? _dataY;
  52. void _recomputeBounds() {
  53. double? x0, x1, y0, y1;
  54. for (final s in widget.series) {
  55. for (final p in s.points) {
  56. if (!p.dx.isFinite || !p.dy.isFinite) continue;
  57. x0 = (x0 == null) ? p.dx : math.min(x0, p.dx);
  58. x1 = (x1 == null) ? p.dx : math.max(x1, p.dx);
  59. y0 = (y0 == null) ? p.dy : math.min(y0, p.dy);
  60. y1 = (y1 == null) ? p.dy : math.max(y1, p.dy);
  61. }
  62. }
  63. if (x0 == null || y0 == null || x1 == null || y1 == null) {
  64. _dataX = null;
  65. _dataY = null;
  66. return;
  67. }
  68. if (x1 - x0 < 1e-12) {
  69. x0 -= 1;
  70. x1 += 1;
  71. }
  72. if (y1 - y0 < 1e-12) {
  73. y0 -= 1;
  74. y1 += 1;
  75. }
  76. // 上下留 5% 余量,别让曲线顶到边
  77. final pad = (y1 - y0) * 0.05;
  78. _dataX = _Bounds(x0, x1);
  79. _dataY = _Bounds(y0 - pad, y1 + pad);
  80. }
  81. @override
  82. void initState() {
  83. super.initState();
  84. _recomputeBounds();
  85. _y0 = widget.yMin ?? _dataY?.min;
  86. _y1 = widget.yMax ?? _dataY?.max;
  87. }
  88. @override
  89. void didUpdateWidget(covariant CurvePlot old) {
  90. super.didUpdateWidget(old);
  91. if (!identical(old.series, widget.series)) {
  92. _recomputeBounds();
  93. }
  94. }
  95. void _reset() => setState(() {
  96. _x0 = _x1 = null;
  97. _y0 = widget.yMin ?? _dataY?.min;
  98. _y1 = widget.yMax ?? _dataY?.max;
  99. });
  100. @override
  101. Widget build(BuildContext context) {
  102. final dx = _dataX;
  103. final dy = _dataY;
  104. if (dx == null || dy == null) {
  105. return const Center(child: Text('没有可绘制的有限点'));
  106. }
  107. final x0 = _x0 ?? dx.min, x1 = _x1 ?? dx.max;
  108. final y0 = _y0 ?? dy.min, y1 = _y1 ?? dy.max;
  109. return Column(
  110. children: [
  111. Expanded(
  112. child: ClipRect(
  113. child: CustomPaint(
  114. painter: _CurvePainter(widget.series, x0, x1, y0, y1),
  115. child: const SizedBox.expand(),
  116. ),
  117. ),
  118. ),
  119. if (widget.showControls) _controls(dx, dy, x0, x1, y0, y1),
  120. ],
  121. );
  122. }
  123. Widget _controls(_Bounds dx, _Bounds dy,
  124. double x0, double x1, double y0, double y1) {
  125. // 滑条可调范围比数据范围宽一倍,这样既能放大也能缩小
  126. final xLo = dx.min - dx.span, xHi = dx.max + dx.span;
  127. final yLo = dy.min - dy.span, yHi = dy.max + dy.span;
  128. return Column(
  129. children: [
  130. _slider('横向', xLo, xHi, x0, x1, (v) => setState(() {
  131. _x0 = v.start;
  132. _x1 = v.end;
  133. })),
  134. _slider('纵向', yLo, yHi, y0, y1, (v) => setState(() {
  135. _y0 = v.start;
  136. _y1 = v.end;
  137. })),
  138. Align(
  139. alignment: Alignment.centerRight,
  140. child: TextButton.icon(
  141. onPressed: _reset,
  142. icon: const Icon(Icons.restart_alt, size: 16),
  143. label: const Text('复位'),
  144. ),
  145. ),
  146. ],
  147. );
  148. }
  149. Widget _slider(String label, double lo, double hi, double start, double end,
  150. void Function(RangeValues) onChanged) {
  151. return Row(
  152. children: [
  153. SizedBox(
  154. width: 32,
  155. child: Text(label, style: const TextStyle(fontSize: 11)),
  156. ),
  157. Expanded(
  158. child: SliderTheme(
  159. data: SliderTheme.of(context).copyWith(
  160. trackHeight: 2,
  161. thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
  162. overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
  163. ),
  164. child: RangeSlider(
  165. min: lo,
  166. max: hi,
  167. // 滑条不允许左右端点交叉,否则画面会翻过来
  168. values: RangeValues(
  169. start.clamp(lo, hi - (hi - lo) * 1e-6),
  170. end.clamp(lo + (hi - lo) * 1e-6, hi),
  171. ),
  172. onChanged: onChanged,
  173. ),
  174. ),
  175. ),
  176. ],
  177. );
  178. }
  179. }
  180. class _CurvePainter extends CustomPainter {
  181. _CurvePainter(this.series, this.x0, this.x1, this.y0, this.y1);
  182. final List<CurveSeries> series;
  183. final double x0, x1, y0, y1;
  184. static const _padLeft = 46.0;
  185. static const _padRight = 12.0;
  186. static const _padTop = 12.0;
  187. static const _padBottom = 26.0;
  188. @override
  189. void paint(Canvas canvas, Size size) {
  190. canvas.drawRect(
  191. Offset.zero & size,
  192. Paint()..color = const Color(0xFFFAFAFC),
  193. );
  194. final plot = Rect.fromLTRB(
  195. _padLeft,
  196. _padTop,
  197. size.width - _padRight,
  198. size.height - _padBottom,
  199. );
  200. double sx(double x) => plot.left + (x - x0) / (x1 - x0) * plot.width;
  201. double sy(double y) => plot.bottom - (y - y0) / (y1 - y0) * plot.height;
  202. canvas.save();
  203. canvas.clipRect(plot);
  204. _grid(canvas, plot, sx, sy);
  205. canvas.restore();
  206. // 坐标轴(可能在可视区外)
  207. final axes = Paint()
  208. ..color = const Color(0xFF9E9E9E)
  209. ..strokeWidth = 1.2;
  210. if (y0 <= 0 && y1 >= 0) {
  211. final y = sy(0);
  212. if (y >= plot.top && y <= plot.bottom) {
  213. canvas.drawLine(Offset(plot.left, y), Offset(plot.right, y), axes);
  214. }
  215. }
  216. if (x0 <= 0 && x1 >= 0) {
  217. final x = sx(0);
  218. if (x >= plot.left && x <= plot.right) {
  219. canvas.drawLine(Offset(x, plot.top), Offset(x, plot.bottom), axes);
  220. }
  221. }
  222. canvas.save();
  223. canvas.clipRect(plot);
  224. for (final s in series) {
  225. final paint = Paint()
  226. ..color = s.color
  227. ..strokeWidth = 2
  228. ..style = PaintingStyle.stroke
  229. ..strokeJoin = StrokeJoin.round;
  230. final path = Path();
  231. var pen = false;
  232. for (final p in s.points) {
  233. if (!p.dx.isFinite || !p.dy.isFinite) {
  234. pen = false; // 断点
  235. continue;
  236. }
  237. final o = Offset(sx(p.dx), sy(p.dy));
  238. if (!pen) {
  239. path.moveTo(o.dx, o.dy);
  240. pen = true;
  241. } else {
  242. path.lineTo(o.dx, o.dy);
  243. }
  244. }
  245. canvas.drawPath(path, paint);
  246. }
  247. canvas.restore();
  248. _axisLabels(canvas, plot, sx, sy);
  249. if (series.length > 1) _legend(canvas, plot);
  250. }
  251. void _grid(Canvas canvas, Rect plot, double Function(double) sx,
  252. double Function(double) sy) {
  253. final paint = Paint()
  254. ..color = const Color(0xFFE8E8EE)
  255. ..strokeWidth = 1;
  256. final stepX = _niceStep(x1 - x0);
  257. for (var x = (x0 / stepX).ceil() * stepX; x <= x1; x += stepX) {
  258. final px = sx(x);
  259. if (px >= plot.left && px <= plot.right) {
  260. canvas.drawLine(Offset(px, plot.top), Offset(px, plot.bottom), paint);
  261. }
  262. }
  263. final stepY = _niceStep(y1 - y0);
  264. for (var y = (y0 / stepY).ceil() * stepY; y <= y1; y += stepY) {
  265. final py = sy(y);
  266. if (py >= plot.top && py <= plot.bottom) {
  267. canvas.drawLine(Offset(plot.left, py), Offset(plot.right, py), paint);
  268. }
  269. }
  270. }
  271. void _axisLabels(Canvas canvas, Rect plot, double Function(double) sx,
  272. double Function(double) sy) {
  273. const c = Color(0xFF757575);
  274. final stepX = _niceStep(x1 - x0);
  275. for (var x = (x0 / stepX).ceil() * stepX; x <= x1; x += stepX) {
  276. final px = sx(x);
  277. if (px < plot.left || px > plot.right) continue;
  278. _text(canvas, _fmt(x), Offset(px - 12, plot.bottom + 6), c, 10);
  279. }
  280. final stepY = _niceStep(y1 - y0);
  281. for (var y = (y0 / stepY).ceil() * stepY; y <= y1; y += stepY) {
  282. final py = sy(y);
  283. if (py < plot.top || py > plot.bottom) continue;
  284. _text(canvas, _fmt(y), Offset(2, py - 6), c, 10);
  285. }
  286. }
  287. void _legend(Canvas canvas, Rect plot) {
  288. var y = plot.top + 4;
  289. for (final s in series) {
  290. canvas.drawRect(
  291. Rect.fromLTWH(plot.right - 108, y + 3, 14, 3),
  292. Paint()..color = s.color,
  293. );
  294. _text(canvas, s.label, Offset(plot.right - 88, y - 3),
  295. const Color(0xFF616161), 10);
  296. y += 14;
  297. }
  298. }
  299. void _text(Canvas canvas, String s, Offset at, Color color, double size) {
  300. final tp = TextPainter(
  301. text: TextSpan(
  302. text: s,
  303. style: TextStyle(color: color, fontSize: size),
  304. ),
  305. textDirection: TextDirection.ltr,
  306. )..layout();
  307. tp.paint(canvas, at);
  308. }
  309. /// 取 1/2/5 这种"好看"的刻度间隔。
  310. static double _niceStep(double span) {
  311. if (span <= 0 || !span.isFinite) return 1;
  312. final raw = span / 6;
  313. final mag = math.pow(10, (math.log(raw) / math.ln10).floor()).toDouble();
  314. for (final m in const [1.0, 2.0, 5.0, 10.0]) {
  315. if (raw <= m * mag) return m * mag;
  316. }
  317. return 10 * mag;
  318. }
  319. static String _fmt(double v) {
  320. if (v == 0) return '0';
  321. final a = v.abs();
  322. if (a >= 1e5 || a < 1e-3) return v.toStringAsExponential(1);
  323. final s = v.toStringAsFixed(3);
  324. return s.replaceFirst(RegExp(r'\.?0+$'), '');
  325. }
  326. @override
  327. bool shouldRepaint(covariant _CurvePainter old) =>
  328. old.series != series ||
  329. old.x0 != x0 ||
  330. old.x1 != x1 ||
  331. old.y0 != y0 ||
  332. old.y1 != y1;
  333. }