| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482 |
- 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,
- });
- }
- /// 曲线绘制组件:自动定界、网格、刻度、多曲线图例。
- ///
- /// **不用手势操作** —— 缩放和平移改成下面两条滑动条。
- /// 图在手机屏幕上很小,双指捏合容易误触,而且缩过头就找不回来了;
- /// 滑条能看出当前窗口在哪、也能一键复位。
- 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<CurveSeries> series;
- /// 由调用方**定死**的显示窗口(给了就不自动定界、也不用下面的滑条)。
- ///
- /// 函数绘图工具把两个范围滑动条搬到了「计算」上方:采样区间和显示窗口
- /// 都由那里决定,图自己就不该再有一套滑条了(两套会互相打架)。
- final (double, double)? xWindow;
- final (double, double)? yWindow;
- /// 纵轴的**初始**窗口(不给就按数据自动定界)。之后由滑动条接管。
- final double? yMin;
- final double? yMax;
- /// 是否显示下面的滑动条。嵌在很小的对话框里时可以关掉。
- final bool showControls;
- /// 等比例:两轴用**同样的单位长度**(圆看起来才是圆)。
- ///
- /// 参数方程、圆、极坐标这类图必须开 —— 默认关掉是因为函数图像
- /// 更希望横向铺满,而不是为了保持比例留出大片空白。
- final bool equalAspect;
- @override
- State<CurvePlot> 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<CurvePlot> {
- /// 当前视图窗口。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<CurveSeries> 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;
- }
|