| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299 |
- 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<Offset> 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<CurveSeries> series;
- /// 显式指定 y 轴范围。不给就按数据自动定界。
- ///
- /// 为什么需要:自动定界对有极点的函数很难看(`Tan[x]` 会被几个巨大的有限值
- /// 撑爆整幅图)。`函数图像绘制` 工具原本就能填 y 范围,这个参数把它接住。
- final double? yMin;
- final double? yMax;
- @override
- State<CurvePlot> createState() => _CurvePlotState();
- }
- class _CurvePlotState extends State<CurvePlot> {
- 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<CurveSeries> 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;
- }
|