curve_plot.dart 12 KB

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