curve_plot.dart 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  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. this.equalAspect = false,
  31. this.onViewChanged,
  32. });
  33. final List<CurveSeries> series;
  34. /// 横向窗口变了就报一声,让调用方有机会**重新采样**。
  35. ///
  36. /// 采样区间是有限的(函数图像只有 [-10, 10]),滑动条一拉出去曲线就断了 ——
  37. /// 用户实测反馈"调整滑动条后图像不会自动延展"。谁采的样谁负责延展,
  38. /// 所以这里只报信,不自己采。
  39. final void Function(double x0, double x1)? onViewChanged;
  40. /// 纵轴的**初始**窗口(不给就按数据自动定界)。之后由滑动条接管。
  41. final double? yMin;
  42. final double? yMax;
  43. /// 是否显示下面的滑动条。嵌在很小的对话框里时可以关掉。
  44. final bool showControls;
  45. /// 等比例:两轴用**同样的单位长度**(圆看起来才是圆)。
  46. ///
  47. /// 参数方程、圆、极坐标这类图必须开 —— 默认关掉是因为函数图像
  48. /// 更希望横向铺满,而不是为了保持比例留出大片空白。
  49. final bool equalAspect;
  50. @override
  51. State<CurvePlot> createState() => _CurvePlotState();
  52. }
  53. /// 数据在各轴上的范围。
  54. class _Bounds {
  55. final double min;
  56. final double max;
  57. const _Bounds(this.min, this.max);
  58. double get span => max - min;
  59. }
  60. /// 把两轴范围调成**同跨度、同中心**(各边留 5% 余量)。
  61. ///
  62. /// 抽成纯函数是为了能直接测 —— "圆是不是圆的"靠截图看像素太难回归了。
  63. /// 同跨度意味着同样的单位长度在两轴上对应同样多的像素。
  64. (double, double, double, double) equalizeAspect(
  65. double x0, double x1, double y0, double y1) {
  66. // 退化成一点(或非法值)时给个最小跨度,否则返回的还是零跨度
  67. var span = math.max(x1 - x0, y1 - y0);
  68. if (!span.isFinite || span <= 0) span = 1;
  69. final pad = span * 0.05;
  70. x0 -= pad;
  71. x1 += pad;
  72. y0 -= pad;
  73. y1 += pad;
  74. final cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
  75. final half = math.max(x1 - x0, y1 - y0) / 2;
  76. return (cx - half, cx + half, cy - half, cy + half);
  77. }
  78. class _CurvePlotState extends State<CurvePlot> {
  79. /// 当前视图窗口。null = 跟着数据自动定界。
  80. double? _x0, _x1, _y0, _y1;
  81. _Bounds? _dataX;
  82. _Bounds? _dataY;
  83. void _recomputeBounds() {
  84. double? x0, x1, y0, y1;
  85. for (final s in widget.series) {
  86. for (final p in s.points) {
  87. if (!p.dx.isFinite || !p.dy.isFinite) continue;
  88. x0 = (x0 == null) ? p.dx : math.min(x0, p.dx);
  89. x1 = (x1 == null) ? p.dx : math.max(x1, p.dx);
  90. y0 = (y0 == null) ? p.dy : math.min(y0, p.dy);
  91. y1 = (y1 == null) ? p.dy : math.max(y1, p.dy);
  92. }
  93. }
  94. if (x0 == null || y0 == null || x1 == null || y1 == null) {
  95. _dataX = null;
  96. _dataY = null;
  97. return;
  98. }
  99. if (x1 - x0 < 1e-12) {
  100. x0 -= 1;
  101. x1 += 1;
  102. }
  103. if (y1 - y0 < 1e-12) {
  104. y0 -= 1;
  105. y1 += 1;
  106. }
  107. if (widget.equalAspect) {
  108. final r = equalizeAspect(x0, x1, y0, y1);
  109. _dataX = _Bounds(r.$1, r.$2);
  110. _dataY = _Bounds(r.$3, r.$4);
  111. return;
  112. }
  113. // 上下留 5% 余量,别让曲线顶到边
  114. final pad = (y1 - y0) * 0.05;
  115. _dataX = _Bounds(x0, x1);
  116. _dataY = _Bounds(y0 - pad, y1 + pad);
  117. }
  118. @override
  119. void initState() {
  120. super.initState();
  121. _recomputeBounds();
  122. _y0 = widget.yMin ?? _dataY?.min;
  123. _y1 = widget.yMax ?? _dataY?.max;
  124. }
  125. @override
  126. void didUpdateWidget(covariant CurvePlot old) {
  127. super.didUpdateWidget(old);
  128. if (!identical(old.series, widget.series)) {
  129. _recomputeBounds();
  130. }
  131. }
  132. void _reset() => setState(() {
  133. _x0 = _x1 = null;
  134. _y0 = widget.yMin ?? _dataY?.min;
  135. _y1 = widget.yMax ?? _dataY?.max;
  136. });
  137. @override
  138. Widget build(BuildContext context) {
  139. final dx = _dataX;
  140. final dy = _dataY;
  141. if (dx == null || dy == null) {
  142. return const Center(child: Text('没有可绘制的有限点'));
  143. }
  144. final x0 = _x0 ?? dx.min, x1 = _x1 ?? dx.max;
  145. final y0 = _y0 ?? dy.min, y1 = _y1 ?? dy.max;
  146. return Column(
  147. children: [
  148. Expanded(
  149. child: ClipRect(
  150. child: CustomPaint(
  151. painter: _CurvePainter(
  152. widget.series, x0, x1, y0, y1, widget.equalAspect),
  153. child: const SizedBox.expand(),
  154. ),
  155. ),
  156. ),
  157. if (widget.showControls) _controls(dx, dy, x0, x1, y0, y1),
  158. ],
  159. );
  160. }
  161. Widget _controls(_Bounds dx, _Bounds dy,
  162. double x0, double x1, double y0, double y1) {
  163. // 滑条可调范围比数据范围宽一倍,这样既能放大也能缩小
  164. final xLo = dx.min - dx.span, xHi = dx.max + dx.span;
  165. final yLo = dy.min - dy.span, yHi = dy.max + dy.span;
  166. return Column(
  167. children: [
  168. _slider('横向', xLo, xHi, x0, x1, (v) {
  169. setState(() {
  170. _x0 = v.start;
  171. _x1 = v.end;
  172. });
  173. widget.onViewChanged?.call(v.start, v.end);
  174. }),
  175. _slider('纵向', yLo, yHi, y0, y1, (v) {
  176. setState(() {
  177. _y0 = v.start;
  178. _y1 = v.end;
  179. });
  180. // 纵向变化不影响 x 方向要不要补采样,但一起报出去省得调用方还要判断
  181. widget.onViewChanged?.call(x0, x1);
  182. }),
  183. Align(
  184. alignment: Alignment.centerRight,
  185. child: TextButton.icon(
  186. onPressed: _reset,
  187. icon: const Icon(Icons.restart_alt, size: 16),
  188. label: const Text('复位'),
  189. ),
  190. ),
  191. ],
  192. );
  193. }
  194. Widget _slider(String label, double lo, double hi, double start, double end,
  195. void Function(RangeValues) onChanged) {
  196. return Row(
  197. children: [
  198. SizedBox(
  199. width: 32,
  200. child: Text(label, style: const TextStyle(fontSize: 11)),
  201. ),
  202. Expanded(
  203. child: SliderTheme(
  204. data: SliderTheme.of(context).copyWith(
  205. trackHeight: 2,
  206. thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
  207. overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
  208. ),
  209. child: RangeSlider(
  210. min: lo,
  211. max: hi,
  212. // 滑条不允许左右端点交叉,否则画面会翻过来
  213. values: RangeValues(
  214. start.clamp(lo, hi - (hi - lo) * 1e-6),
  215. end.clamp(lo + (hi - lo) * 1e-6, hi),
  216. ),
  217. onChanged: onChanged,
  218. ),
  219. ),
  220. ),
  221. ],
  222. );
  223. }
  224. }
  225. class _CurvePainter extends CustomPainter {
  226. _CurvePainter(
  227. this.series, this.x0, this.x1, this.y0, this.y1, this.equalAspect);
  228. final List<CurveSeries> series;
  229. final double x0, x1, y0, y1;
  230. final bool equalAspect;
  231. static const _padLeft = 46.0;
  232. static const _padRight = 12.0;
  233. static const _padTop = 12.0;
  234. static const _padBottom = 26.0;
  235. @override
  236. void paint(Canvas canvas, Size size) {
  237. canvas.drawRect(
  238. Offset.zero & size,
  239. Paint()..color = const Color(0xFFFAFAFC),
  240. );
  241. var plot = Rect.fromLTRB(
  242. _padLeft,
  243. _padTop,
  244. size.width - _padRight,
  245. size.height - _padBottom,
  246. );
  247. if (equalAspect) {
  248. // 把绘图区收成正方形 —— 否则宽度比高度少掉两边的轴标签宽度,
  249. // 画出来的圆就是椭的。
  250. final side = math.min(plot.width, plot.height);
  251. plot = Rect.fromCenter(center: plot.center, width: side, height: side);
  252. }
  253. double sx(double x) => plot.left + (x - x0) / (x1 - x0) * plot.width;
  254. double sy(double y) => plot.bottom - (y - y0) / (y1 - y0) * plot.height;
  255. canvas.save();
  256. canvas.clipRect(plot);
  257. _grid(canvas, plot, sx, sy);
  258. canvas.restore();
  259. // 坐标轴(可能在可视区外)
  260. final axes = Paint()
  261. ..color = const Color(0xFF9E9E9E)
  262. ..strokeWidth = 1.2;
  263. if (y0 <= 0 && y1 >= 0) {
  264. final y = sy(0);
  265. if (y >= plot.top && y <= plot.bottom) {
  266. canvas.drawLine(Offset(plot.left, y), Offset(plot.right, y), axes);
  267. }
  268. }
  269. if (x0 <= 0 && x1 >= 0) {
  270. final x = sx(0);
  271. if (x >= plot.left && x <= plot.right) {
  272. canvas.drawLine(Offset(x, plot.top), Offset(x, plot.bottom), axes);
  273. }
  274. }
  275. canvas.save();
  276. canvas.clipRect(plot);
  277. for (final s in series) {
  278. final paint = Paint()
  279. ..color = s.color
  280. ..strokeWidth = 2
  281. ..style = PaintingStyle.stroke
  282. ..strokeJoin = StrokeJoin.round;
  283. final path = Path();
  284. var pen = false;
  285. for (final p in s.points) {
  286. if (!p.dx.isFinite || !p.dy.isFinite) {
  287. pen = false; // 断点
  288. continue;
  289. }
  290. final o = Offset(sx(p.dx), sy(p.dy));
  291. if (!pen) {
  292. path.moveTo(o.dx, o.dy);
  293. pen = true;
  294. } else {
  295. path.lineTo(o.dx, o.dy);
  296. }
  297. }
  298. canvas.drawPath(path, paint);
  299. }
  300. canvas.restore();
  301. _axisLabels(canvas, plot, sx, sy);
  302. if (series.length > 1) _legend(canvas, plot);
  303. }
  304. void _grid(Canvas canvas, Rect plot, double Function(double) sx,
  305. double Function(double) sy) {
  306. final paint = Paint()
  307. ..color = const Color(0xFFE8E8EE)
  308. ..strokeWidth = 1;
  309. final stepX = _niceStep(x1 - x0);
  310. for (var x = (x0 / stepX).ceil() * stepX; x <= x1; x += stepX) {
  311. final px = sx(x);
  312. if (px >= plot.left && px <= plot.right) {
  313. canvas.drawLine(Offset(px, plot.top), Offset(px, plot.bottom), paint);
  314. }
  315. }
  316. final stepY = _niceStep(y1 - y0);
  317. for (var y = (y0 / stepY).ceil() * stepY; y <= y1; y += stepY) {
  318. final py = sy(y);
  319. if (py >= plot.top && py <= plot.bottom) {
  320. canvas.drawLine(Offset(plot.left, py), Offset(plot.right, py), paint);
  321. }
  322. }
  323. }
  324. void _axisLabels(Canvas canvas, Rect plot, double Function(double) sx,
  325. double Function(double) sy) {
  326. const c = Color(0xFF757575);
  327. final stepX = _niceStep(x1 - x0);
  328. for (var x = (x0 / stepX).ceil() * stepX; x <= x1; x += stepX) {
  329. final px = sx(x);
  330. if (px < plot.left || px > plot.right) continue;
  331. _text(canvas, _fmt(x), Offset(px - 12, plot.bottom + 6), c, 10);
  332. }
  333. final stepY = _niceStep(y1 - y0);
  334. for (var y = (y0 / stepY).ceil() * stepY; y <= y1; y += stepY) {
  335. final py = sy(y);
  336. if (py < plot.top || py > plot.bottom) continue;
  337. _text(canvas, _fmt(y), Offset(2, py - 6), c, 10);
  338. }
  339. }
  340. void _legend(Canvas canvas, Rect plot) {
  341. var y = plot.top + 4;
  342. for (final s in series) {
  343. canvas.drawRect(
  344. Rect.fromLTWH(plot.right - 108, y + 3, 14, 3),
  345. Paint()..color = s.color,
  346. );
  347. _text(canvas, s.label, Offset(plot.right - 88, y - 3),
  348. const Color(0xFF616161), 10);
  349. y += 14;
  350. }
  351. }
  352. void _text(Canvas canvas, String s, Offset at, Color color, double size) {
  353. final tp = TextPainter(
  354. text: TextSpan(
  355. text: s,
  356. style: TextStyle(color: color, fontSize: size),
  357. ),
  358. textDirection: TextDirection.ltr,
  359. )..layout();
  360. tp.paint(canvas, at);
  361. }
  362. /// 取 1/2/5 这种"好看"的刻度间隔。
  363. static double _niceStep(double span) {
  364. if (span <= 0 || !span.isFinite) return 1;
  365. final raw = span / 6;
  366. final mag = math.pow(10, (math.log(raw) / math.ln10).floor()).toDouble();
  367. for (final m in const [1.0, 2.0, 5.0, 10.0]) {
  368. if (raw <= m * mag) return m * mag;
  369. }
  370. return 10 * mag;
  371. }
  372. static String _fmt(double v) {
  373. if (v == 0) return '0';
  374. final a = v.abs();
  375. if (a >= 1e5 || a < 1e-3) return v.toStringAsExponential(1);
  376. final s = v.toStringAsFixed(3);
  377. return s.replaceFirst(RegExp(r'\.?0+$'), '');
  378. }
  379. @override
  380. bool shouldRepaint(covariant _CurvePainter old) =>
  381. old.series != series ||
  382. old.x0 != x0 ||
  383. old.x1 != x1 ||
  384. old.y0 != y0 ||
  385. old.y1 != y1;
  386. }