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, }); } /// 曲线绘制组件:自动定界、网格、刻度、多曲线图例。 /// /// **不用手势操作** —— 缩放和平移改成下面两条滑动条。 /// 图在手机屏幕上很小,双指捏合容易误触,而且缩过头就找不回来了; /// 滑条能看出当前窗口在哪、也能一键复位。 class CurvePlot extends StatefulWidget { const CurvePlot({ super.key, required this.series, this.yMin, this.yMax, this.showControls = true, this.equalAspect = false, this.xWindow, this.yWindow, }); final List series; /// 由调用方**定死**的显示窗口(给了就不自动定界、也不用下面的滑条)。 /// /// 函数绘图工具把两个范围滑动条搬到了「计算」上方:采样区间和显示窗口 /// 都由那里决定,图自己就不该再有一套滑条了(两套会互相打架)。 final (double, double)? xWindow; final (double, double)? yWindow; /// 纵轴的**初始**窗口(不给就按数据自动定界)。之后由滑动条接管。 final double? yMin; final double? yMax; /// 是否显示下面的滑动条。嵌在很小的对话框里时可以关掉。 final bool showControls; /// 等比例:两轴用**同样的单位长度**(圆看起来才是圆)。 /// /// 参数方程、圆、极坐标这类图必须开 —— 默认关掉是因为函数图像 /// 更希望横向铺满,而不是为了保持比例留出大片空白。 final bool equalAspect; @override State createState() => _CurvePlotState(); } /// 数据在各轴上的范围。 class _Bounds { final double min; final double max; const _Bounds(this.min, this.max); double get span => max - min; } /// 把两轴范围调成**同跨度、同中心**(各边留 5% 余量)。 /// /// 抽成纯函数是为了能直接测 —— "圆是不是圆的"靠截图看像素太难回归了。 /// 同跨度意味着同样的单位长度在两轴上对应同样多的像素。 (double, double, double, double) equalizeAspect( double x0, double x1, double y0, double y1, ) { // 退化成一点(或非法值)时给个最小跨度,否则返回的还是零跨度 var span = math.max(x1 - x0, y1 - y0); if (!span.isFinite || span <= 0) span = 1; final pad = span * 0.05; x0 -= pad; x1 += pad; y0 -= pad; y1 += pad; final cx = (x0 + x1) / 2, cy = (y0 + y1) / 2; final half = math.max(x1 - x0, y1 - y0) / 2; return (cx - half, cx + half, cy - half, cy + half); } class _CurvePlotState extends State { /// 当前视图窗口。null = 跟着数据自动定界。 double? _x0, _x1, _y0, _y1; _Bounds? _dataX; _Bounds? _dataY; void _recomputeBounds() { double? x0, x1, y0, y1; for (final s in widget.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) { _dataX = null; _dataY = null; return; } if (x1 - x0 < 1e-12) { x0 -= 1; x1 += 1; } if (y1 - y0 < 1e-12) { y0 -= 1; y1 += 1; } if (widget.equalAspect) { final r = equalizeAspect(x0, x1, y0, y1); _dataX = _Bounds(r.$1, r.$2); _dataY = _Bounds(r.$3, r.$4); return; } // 上下留 5% 余量,别让曲线顶到边 final pad = (y1 - y0) * 0.05; _dataX = _Bounds(x0, x1); _dataY = _Bounds(y0 - pad, y1 + pad); } @override void initState() { super.initState(); _recomputeBounds(); _y0 = widget.yMin ?? _dataY?.min; _y1 = widget.yMax ?? _dataY?.max; } @override void didUpdateWidget(covariant CurvePlot old) { super.didUpdateWidget(old); if (!identical(old.series, widget.series)) { _recomputeBounds(); } } void _reset() => setState(() { _x0 = _x1 = null; _y0 = widget.yMin ?? _dataY?.min; _y1 = widget.yMax ?? _dataY?.max; }); @override Widget build(BuildContext context) { final dx = _dataX; final dy = _dataY; if (dx == null || dy == null) { return const Center(child: Text('没有可绘制的有限点')); } final x0 = widget.xWindow?.$1 ?? _x0 ?? dx.min; final x1 = widget.xWindow?.$2 ?? _x1 ?? dx.max; final y0 = widget.yWindow?.$1 ?? _y0 ?? dy.min; final y1 = widget.yWindow?.$2 ?? _y1 ?? dy.max; return Column( children: [ Expanded( child: ClipRect( child: CustomPaint( painter: _CurvePainter( widget.series, x0, x1, y0, y1, widget.equalAspect, ), child: const SizedBox.expand(), ), ), ), if (widget.showControls && widget.xWindow == null && widget.yWindow == null) _controls(dx, dy, x0, x1, y0, y1), ], ); } Widget _controls( _Bounds dx, _Bounds dy, double x0, double x1, double y0, double y1, ) { // 滑条可调范围比数据范围宽一倍,这样既能放大也能缩小 final xLo = dx.min - dx.span, xHi = dx.max + dx.span; final yLo = dy.min - dy.span, yHi = dy.max + dy.span; return Column( children: [ _slider( '横向', xLo, xHi, x0, x1, (v) => setState(() { _x0 = v.start; _x1 = v.end; }), ), _slider( '纵向', yLo, yHi, y0, y1, (v) => setState(() { _y0 = v.start; _y1 = v.end; }), ), Align( alignment: Alignment.centerRight, child: TextButton.icon( onPressed: _reset, icon: const Icon(Icons.restart_alt, size: 16), label: const Text('复位'), ), ), ], ); } Widget _slider( String label, double lo, double hi, double start, double end, void Function(RangeValues) onChanged, ) { return Row( children: [ SizedBox( width: 32, child: Text(label, style: const TextStyle(fontSize: 11)), ), Expanded( child: SliderTheme( data: SliderTheme.of(context).copyWith( trackHeight: 2, thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7), overlayShape: const RoundSliderOverlayShape(overlayRadius: 14), ), child: RangeSlider( min: lo, max: hi, // 滑条不允许左右端点交叉,否则画面会翻过来 values: RangeValues( start.clamp(lo, hi - (hi - lo) * 1e-6), end.clamp(lo + (hi - lo) * 1e-6, hi), ), onChanged: onChanged, ), ), ), ], ); } } class _CurvePainter extends CustomPainter { _CurvePainter( this.series, this.x0, this.x1, this.y0, this.y1, this.equalAspect, ); final List series; final double x0, x1, y0, y1; final bool equalAspect; 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), ); var plot = Rect.fromLTRB( _padLeft, _padTop, size.width - _padRight, size.height - _padBottom, ); if (equalAspect) { // 把绘图区收成正方形 —— 否则宽度比高度少掉两边的轴标签宽度, // 画出来的圆就是椭的。 final side = math.min(plot.width, plot.height); plot = Rect.fromCenter(center: plot.center, width: side, height: side); } double sx(double x) => plot.left + (x - x0) / (x1 - x0) * plot.width; double sy(double y) => plot.bottom - (y - y0) / (y1 - y0) * plot.height; canvas.save(); canvas.clipRect(plot); _grid(canvas, plot, 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, sx, sy); if (series.length > 1) _legend(canvas, plot); } void _grid( Canvas canvas, Rect plot, 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 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.x0 != x0 || old.x1 != x1 || old.y0 != y0 || old.y1 != y1; }