curve_plot.dart 13 KB

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