import 'dart:math' as math; import 'package:flutter/material.dart'; /// 一条曲线。点用数据坐标;任一分量为 NaN 表示断点(画笔抬起)。 /// /// 用 (x, y) 点对而不是「y 数组 + x 范围」,是为了同一个组件能画 /// 函数曲线、参数方程、极坐标 —— 后两者的 x 和 y 都在变。 @immutable class CurveSeries { final String label; final List points; final Color color; const CurveSeries({ required this.label, required this.points, required this.color, }); } /// 曲线绘制组件:自动定界、网格、刻度、多曲线图例、双指缩放/拖动、双击复位。 /// /// 放在 lib/ 顶层而不是 tools/ 下:控制台的 `plot` 命令和计算器都要用它, /// mathtool2 搬过来的「函数图像绘制 / 参数方程绘图」也换成它, /// 避免维护两套绘图。 class CurvePlot extends StatefulWidget { const CurvePlot({ super.key, required this.series, this.yMin, this.yMax, }); final List series; /// 显式指定 y 轴范围。不给就按数据自动定界。 /// /// 为什么需要:自动定界对有极点的函数很难看(`Tan[x]` 会被几个巨大的有限值 /// 撑爆整幅图)。`函数图像绘制` 工具原本就能填 y 范围,这个参数把它接住。 final double? yMin; final double? yMax; @override State createState() => _CurvePlotState(); } class _CurvePlotState extends State { double _zoom = 1; Offset _pan = Offset.zero; double _zoomStart = 1; Offset _panStart = Offset.zero; void _reset() => setState(() { _zoom = 1; _pan = Offset.zero; }); @override Widget build(BuildContext context) { return GestureDetector( onDoubleTap: _reset, onScaleStart: (_) { _zoomStart = _zoom; _panStart = _pan; }, onScaleUpdate: (d) => setState(() { _zoom = (_zoomStart * d.scale).clamp(0.2, 50.0); _pan = _panStart + d.focalPointDelta; }), child: ClipRect( child: CustomPaint( painter: _CurvePainter( widget.series, _zoom, _pan, yMin: widget.yMin, yMax: widget.yMax, ), child: const SizedBox.expand(), ), ), ); } } class _CurvePainter extends CustomPainter { _CurvePainter(this.series, this.zoom, this.pan, {this.yMin, this.yMax}); final List series; final double zoom; final Offset pan; final double? yMin; final double? yMax; static const _padLeft = 46.0; static const _padRight = 12.0; static const _padTop = 12.0; static const _padBottom = 26.0; @override void paint(Canvas canvas, Size size) { canvas.drawRect( Offset.zero & size, Paint()..color = const Color(0xFFFAFAFC), ); // 数据边界 double? x0, x1, y0, y1; for (final s in series) { for (final p in s.points) { if (!p.dx.isFinite || !p.dy.isFinite) continue; x0 = (x0 == null) ? p.dx : math.min(x0, p.dx); x1 = (x1 == null) ? p.dx : math.max(x1, p.dx); y0 = (y0 == null) ? p.dy : math.min(y0, p.dy); y1 = (y1 == null) ? p.dy : math.max(y1, p.dy); } } if (x0 == null || y0 == null || x1 == null || y1 == null) { _text(canvas, '没有可绘制的有限点', Offset(size.width / 2 - 60, size.height / 2), const Color(0xFF9E9E9E), 13); return; } if (x1 - x0 < 1e-12) { x0 -= 1; x1 += 1; } // 显式给了 y 范围就用它 —— 自动定界碰上带极点的函数会被几个大值撑爆(Tan[x] 那种) final fixedYMin = yMin; final fixedYMax = yMax; if (fixedYMin != null) y0 = fixedYMin; if (fixedYMax != null) y1 = fixedYMax; if (y1 - y0 < 1e-12) { y0 -= 1; y1 += 1; } final plot = Rect.fromLTRB( _padLeft, _padTop, size.width - _padRight, size.height - _padBottom, ); // 数据 -> 屏幕 double sx(double x) { final t = (x - x0!) / (x1! - x0); return plot.left + (plot.width / 2) + (t * plot.width - plot.width / 2) * zoom + pan.dx; } double sy(double y) { final t = (y - y0!) / (y1! - y0); return plot.bottom - (plot.height / 2) - (t * plot.height - plot.height / 2) * zoom + pan.dy; } // 内边距裁掉越界部分,避免曲线画到坐标轴外面 canvas.save(); canvas.clipRect(plot); _grid(canvas, plot, x0, x1, y0, y1, sx, sy); canvas.restore(); // 坐标轴(可能落在可视区外,用画布坐标直接画) final axes = Paint() ..color = const Color(0xFF9E9E9E) ..strokeWidth = 1.2; if (y0 <= 0 && y1 >= 0) { final y = sy(0); if (y >= plot.top && y <= plot.bottom) { canvas.drawLine(Offset(plot.left, y), Offset(plot.right, y), axes); } } if (x0 <= 0 && x1 >= 0) { final x = sx(0); if (x >= plot.left && x <= plot.right) { canvas.drawLine(Offset(x, plot.top), Offset(x, plot.bottom), axes); } } canvas.save(); canvas.clipRect(plot); for (final s in series) { final paint = Paint() ..color = s.color ..strokeWidth = 2 ..style = PaintingStyle.stroke ..strokeJoin = StrokeJoin.round; final path = Path(); var pen = false; for (final p in s.points) { if (!p.dx.isFinite || !p.dy.isFinite) { pen = false; // 断点 continue; } final o = Offset(sx(p.dx), sy(p.dy)); if (!pen) { path.moveTo(o.dx, o.dy); pen = true; } else { path.lineTo(o.dx, o.dy); } } canvas.drawPath(path, paint); } canvas.restore(); _axisLabels(canvas, plot, x0, x1, y0, y1, sx, sy); if (series.length > 1) _legend(canvas, plot); } void _grid(Canvas canvas, Rect plot, double x0, double x1, double y0, double y1, double Function(double) sx, double Function(double) sy) { final paint = Paint() ..color = const Color(0xFFE8E8EE) ..strokeWidth = 1; final stepX = _niceStep(x1 - x0); for (var x = (x0 / stepX).ceil() * stepX; x <= x1; x += stepX) { final px = sx(x); if (px >= plot.left && px <= plot.right) { canvas.drawLine(Offset(px, plot.top), Offset(px, plot.bottom), paint); } } final stepY = _niceStep(y1 - y0); for (var y = (y0 / stepY).ceil() * stepY; y <= y1; y += stepY) { final py = sy(y); if (py >= plot.top && py <= plot.bottom) { canvas.drawLine(Offset(plot.left, py), Offset(plot.right, py), paint); } } } void _axisLabels(Canvas canvas, Rect plot, double x0, double x1, double y0, double y1, double Function(double) sx, double Function(double) sy) { const c = Color(0xFF757575); final stepX = _niceStep(x1 - x0); for (var x = (x0 / stepX).ceil() * stepX; x <= x1; x += stepX) { final px = sx(x); if (px < plot.left || px > plot.right) continue; _text(canvas, _fmt(x), Offset(px - 12, plot.bottom + 6), c, 10); } final stepY = _niceStep(y1 - y0); for (var y = (y0 / stepY).ceil() * stepY; y <= y1; y += stepY) { final py = sy(y); if (py < plot.top || py > plot.bottom) continue; _text(canvas, _fmt(y), Offset(2, py - 6), c, 10); } } void _legend(Canvas canvas, Rect plot) { var y = plot.top + 4; for (final s in series) { canvas.drawRect( Rect.fromLTWH(plot.right - 108, y + 3, 14, 3), Paint()..color = s.color, ); _text(canvas, s.label, Offset(plot.right - 88, y - 3), const Color(0xFF616161), 10); y += 14; } } void _text(Canvas canvas, String s, Offset at, Color color, double size) { final tp = TextPainter( text: TextSpan( text: s, style: TextStyle(color: color, fontSize: size), ), textDirection: TextDirection.ltr, )..layout(); tp.paint(canvas, at); } /// 取 1/2/5 这种"好看"的刻度间隔。 static double _niceStep(double span) { if (span <= 0 || !span.isFinite) return 1; final raw = span / 6; final mag = math.pow(10, (math.log(raw) / math.ln10).floor()).toDouble(); for (final m in const [1.0, 2.0, 5.0, 10.0]) { if (raw <= m * mag) return m * mag; } return 10 * mag; } static String _fmt(double v) { if (v == 0) return '0'; final a = v.abs(); if (a >= 1e5 || a < 1e-3) return v.toStringAsExponential(1); final s = v.toStringAsFixed(3); return s.replaceFirst(RegExp(r'\.?0+$'), ''); } @override bool shouldRepaint(covariant _CurvePainter old) => old.series != series || old.zoom != zoom || old.pan != pan || old.yMin != yMin || old.yMax != yMax; }