import 'dart:math'; import 'package:flutter/material.dart'; import 'package:k_chart_plus/chart_translations.dart'; import 'package:k_chart_plus/k_chart_plus.dart'; class DepthChart extends StatefulWidget { final List bids, asks; final int baseUnit; final int quoteUnit; final Offset offset; final ChartColors chartColors; final DepthChartTranslations chartTranslations; /// 全量数据的最大累计量,缩放时 Y 轴范围不变 double get fullMaxVolume { if (bids.isEmpty && asks.isEmpty) return 0; double maxVol = 0; if (bids.isNotEmpty) maxVol = max(maxVol, bids.first.vol); if (asks.isNotEmpty) maxVol = max(maxVol, asks.last.vol); return maxVol * 1.05; } DepthChart( this.bids, this.asks, this.chartColors, { this.baseUnit = 2, this.quoteUnit = 6, this.offset = const Offset(10, 10), this.chartTranslations = const DepthChartTranslations(), }); @override _DepthChartState createState() => _DepthChartState(); } class _DepthChartState extends State { Offset? pressOffset; bool isLongPress = false; double _scale = 1.0; // 缩放倍数:1.0=全量,放大时缩小价格可视范围 double _lastScale = 1.0; /// 根据缩放截取买卖盘的可视价格范围。 /// 放大时只显示中间价附近的档位,但保留累计量(不从 0 开始)。 List _visibleBids() { if (_scale <= 1.0 || widget.bids.length <= 2) return widget.bids; final count = (widget.bids.length / _scale).ceil().clamp(2, widget.bids.length); // 买盘:取末尾 count 个(靠近中间价),保留原始累计量 return widget.bids.sublist(widget.bids.length - count); } List _visibleAsks() { if (_scale <= 1.0 || widget.asks.length <= 2) return widget.asks; final count = (widget.asks.length / _scale).ceil().clamp(2, widget.asks.length); // 卖盘:取前 count 个(靠近中间价),保留原始累计量 return widget.asks.sublist(0, count); } @override Widget build(BuildContext context) { return GestureDetector( onScaleStart: (_) => _lastScale = _scale, onScaleUpdate: (details) { if (details.scale != 1.0) { setState(() { _scale = (_lastScale * details.scale).clamp(1.0, 10.0); }); } }, onLongPressStart: (details) { pressOffset = details.localPosition; isLongPress = true; setState(() {}); }, onLongPressMoveUpdate: (details) { pressOffset = details.localPosition; isLongPress = true; setState(() {}); }, onLongPressEnd: (details) { pressOffset = null; isLongPress = false; setState(() {}); }, child: CustomPaint( size: Size(double.infinity, double.infinity), painter: DepthChartPainter( _visibleBids(), _visibleAsks(), pressOffset, isLongPress, widget.baseUnit, widget.quoteUnit, widget.chartColors, widget.offset, widget.chartTranslations, fixedMaxVolume: widget.fullMaxVolume, ), ), ); } } class DepthChartPainter extends CustomPainter { //Buy//Sell List? mBuyData, mSellData; Offset? pressOffset; bool isLongPress; int baseUnit; int quoteUnit; ChartColors chartColors; double mPaddingBottom = 32.0; double mWidth = 0.0, mDrawHeight = 0.0, mDrawWidth = 0.0; double? mBuyPointWidth, mSellPointWidth; Offset offset; DepthChartTranslations chartTranslations; //最大的委托量(fixedMaxVolume 用于缩放时固定 Y 轴范围) double? mMaxVolume, mMultiple; double? fixedMaxVolume; //右侧绘制个数 int mLineCount = 4; Path? mBuyPath, mSellPath; //买卖出区域边线绘制画笔 //买卖出取悦绘制画笔 Paint? mBuyLinePaint, mSellLinePaint, mBuyPathPaint, mSellPathPaint, selectPaint, selectBorderPaint; DepthChartPainter( this.mBuyData, this.mSellData, this.pressOffset, this.isLongPress, this.baseUnit, this.quoteUnit, this.chartColors, this.offset, this.chartTranslations, { this.fixedMaxVolume, }) { mBuyLinePaint ??= Paint() ..isAntiAlias = true ..color = this.chartColors.depthBuyColor ..style = PaintingStyle.stroke ..strokeWidth = 1.5; mSellLinePaint ??= Paint() ..isAntiAlias = true ..color = this.chartColors.depthSellColor ..style = PaintingStyle.stroke ..strokeWidth = 1.5; // 渐变 Paint 在 paint() 里按实际尺寸重建,这里仅占位 mBuyPathPaint ??= Paint()..isAntiAlias = true; mSellPathPaint ??= Paint()..isAntiAlias = true; mBuyPath ??= Path(); mSellPath ??= Path(); init(); } void init() { if (mBuyData == null || mBuyData!.isEmpty || mSellData == null || mSellData!.isEmpty) return; if (fixedMaxVolume != null && fixedMaxVolume! > 0) { // 缩放时使用固定的 Y 轴范围,保持曲线形状一致 mMaxVolume = fixedMaxVolume; } else { mMaxVolume = mBuyData![0].vol; mMaxVolume = max(mMaxVolume!, mSellData!.last.vol); mMaxVolume = mMaxVolume! * 1.05; } mMultiple = mMaxVolume! / mLineCount; selectPaint = Paint() ..isAntiAlias = true ..color = chartColors.selectFillColor; selectBorderPaint = Paint() ..isAntiAlias = true ..color = chartColors.selectBorderColor ..style = PaintingStyle.stroke ..strokeWidth = 0.4; } @override void paint(Canvas canvas, Size size) { if (mBuyData == null || mSellData == null || mBuyData!.isEmpty || mSellData!.isEmpty) return; mWidth = size.width; mDrawWidth = mWidth / 2; mDrawHeight = size.height - mPaddingBottom; // 用实际高度创建垂直渐变(顶部 0.5 → 底部 0.08),匹配原型设计 final bidGradient = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ chartColors.depthBuyColor.withOpacity(0.5), chartColors.depthBuyColor.withOpacity(0.08), ], ); final askGradient = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ chartColors.depthSellColor.withOpacity(0.5), chartColors.depthSellColor.withOpacity(0.08), ], ); mBuyPathPaint = Paint() ..isAntiAlias = true ..shader = bidGradient.createShader( Rect.fromLTWH(0, 0, mDrawWidth, mDrawHeight)); mSellPathPaint = Paint() ..isAntiAlias = true ..shader = askGradient.createShader( Rect.fromLTWH(mDrawWidth, 0, mDrawWidth, mDrawHeight)); canvas.save(); drawBuy(canvas); drawSell(canvas); drawText(canvas); canvas.restore(); } void drawBuy(Canvas canvas) { mBuyPointWidth = (mDrawWidth / (mBuyData!.length - 1 == 0 ? 1 : mBuyData!.length - 1)); mBuyPath!.reset(); double x; double y; for (int i = 0; i < mBuyData!.length; i++) { if (i == 0) { mBuyPath!.moveTo(0, getY(mBuyData![0].vol)); } x = mBuyPointWidth! * i; y = getY(mBuyData![i].vol); if (i >= 1) { canvas.drawLine( Offset(mBuyPointWidth! * (i - 1), getY(mBuyData![i - 1].vol)), Offset(x, y), mBuyLinePaint!); } if (i != mBuyData!.length - 1) { mBuyPath!.quadraticBezierTo( x, y, mBuyPointWidth! * (i + 1), getY(mBuyData![i + 1].vol)); } else { if (i == 0) { mBuyPath!.lineTo(mDrawWidth, y); mBuyPath!.lineTo(mDrawWidth, mDrawHeight); mBuyPath!.lineTo(0, mDrawHeight); } else { mBuyPath!.quadraticBezierTo(x, y, x, mDrawHeight); mBuyPath!.quadraticBezierTo(x, mDrawHeight, 0, mDrawHeight); } mBuyPath!.close(); } } canvas.drawPath(mBuyPath!, mBuyPathPaint!); } void drawSell(Canvas canvas) { mSellPointWidth = (mDrawWidth / (mSellData!.length - 1 == 0 ? 1 : mSellData!.length - 1)); mSellPath!.reset(); double x; double y; for (int i = 0; i < mSellData!.length; i++) { if (i == 0) { mSellPath!.moveTo(mDrawWidth, getY(mSellData![0].vol)); } x = (mSellPointWidth! * i) + mDrawWidth; y = getY(mSellData![i].vol); if (i >= 1) { canvas.drawLine( Offset((mSellPointWidth! * (i - 1)) + mDrawWidth, getY(mSellData![i - 1].vol)), Offset(x, y), mSellLinePaint!); } if (i != mSellData!.length - 1) { mSellPath!.quadraticBezierTo( x, y, (mSellPointWidth! * (i + 1)) + mDrawWidth, getY(mSellData![i + 1].vol)); } else { if (i == 0) { mSellPath!.lineTo(mWidth, y); mSellPath!.lineTo(mWidth, mDrawHeight); mSellPath!.lineTo(mDrawWidth, mDrawHeight); } else { mSellPath!.quadraticBezierTo(mWidth, y, x, mDrawHeight); mSellPath!.quadraticBezierTo(x, mDrawHeight, mDrawWidth, mDrawHeight); } mSellPath!.close(); } } canvas.drawPath(mSellPath!, mSellPathPaint!); } // int? mLastPosition; void drawText(Canvas canvas) { double value; String str; for (int j = 0; j < mLineCount; j++) { value = mMaxVolume! - mMultiple! * j; str = value.toStringAsFixed(baseUnit); var tp = getTextPainter(str); tp.layout(); tp.paint( canvas, Offset( mWidth - tp.width, mDrawHeight / mLineCount * j + tp.height / 2)); } var startText = mBuyData!.first.price.toStringAsFixed(quoteUnit); TextPainter startTP = getTextPainter(startText); startTP.layout(); startTP.paint(canvas, Offset(0, getBottomTextY(startTP.height))); double centerPrice = (mBuyData!.last.price + mSellData!.first.price) / 2; var center = centerPrice.toStringAsFixed(quoteUnit); TextPainter centerTP = getTextPainter(center); centerTP.layout(); centerTP.paint( canvas, Offset( mDrawWidth - centerTP.width / 2, getBottomTextY(centerTP.height))); var endText = mSellData!.last.price.toStringAsFixed(quoteUnit); TextPainter endTP = getTextPainter(endText); endTP.layout(); endTP.paint( canvas, Offset(mWidth - endTP.width, getBottomTextY(endTP.height))); var leftHalfText = ((mBuyData!.first.price + centerPrice) / 2).toStringAsFixed(quoteUnit); TextPainter leftHalfTP = getTextPainter(leftHalfText); leftHalfTP.layout(); leftHalfTP.paint( canvas, Offset((mDrawWidth - leftHalfTP.width) / 2, getBottomTextY(leftHalfTP.height))); var rightHalfText = ((mSellData!.last.price + centerPrice) / 2).toStringAsFixed(quoteUnit); TextPainter rightHalfTP = getTextPainter(rightHalfText); rightHalfTP.layout(); rightHalfTP.paint( canvas, Offset((mDrawWidth + mWidth - rightHalfTP.width) / 2, getBottomTextY(rightHalfTP.height))); if (isLongPress == true) { if (pressOffset!.dx <= mDrawWidth) { int index = _indexOfTranslateX( pressOffset!.dx, 0, mBuyData!.length - 1, getBuyX); drawSelectView(canvas, index, true); } else { int index = _indexOfTranslateX( pressOffset!.dx, 0, mSellData!.length - 1, getSellX); drawSelectView(canvas, index, false); } } } void drawSelectView(Canvas canvas, int index, bool isLeft) { DepthEntity entity = isLeft ? mBuyData![index] : mSellData![index]; double dx = isLeft ? getBuyX(index) : getSellX(index); double dy = getY(entity.vol); double radius = 8.0; if (dx < mDrawWidth) { canvas.drawCircle(Offset(dx, dy), radius / 3, mBuyLinePaint!..style = PaintingStyle.fill); canvas.drawCircle( Offset(dx, dy), radius, mBuyLinePaint!..style = PaintingStyle.stroke); } else { canvas.drawCircle(Offset(dx, dy), radius / 3, mSellLinePaint!..style = PaintingStyle.fill); canvas.drawCircle(Offset(dx, dy), radius, mSellLinePaint!..style = PaintingStyle.stroke); } ///draw popup info /// _PopupPainter popupPainter = _PopupPainter( chartTranslations: this.chartTranslations, chartColors: this.chartColors, price: entity.price.toStringAsFixed(quoteUnit), amount: entity.vol.toStringAsFixed(baseUnit), ); dx = dx < mDrawWidth ? dx + offset.dx : dx - offset.dx - popupPainter.width; dy = dy < mDrawHeight / 2 ? dy + offset.dy : dy - offset.dy - popupPainter.height; Rect rect = Rect.fromLTWH(dx, dy, popupPainter.width, popupPainter.height); RRect boxRect = RRect.fromRectAndRadius(rect, Radius.circular(2.5)); canvas.drawRRect(boxRect, selectPaint!); canvas.drawRRect(boxRect, selectBorderPaint!); popupPainter.paint(canvas, rect.topLeft); } ///Binary search for current value: index int _indexOfTranslateX(double translateX, int start, int end, Function getX) { if (end == start || end == -1) { return start; } if (end - start == 1) { double startValue = getX(start); double endValue = getX(end); return (translateX - startValue).abs() < (translateX - endValue).abs() ? start : end; } int mid = start + (end - start) ~/ 2; double midValue = getX(mid); if (translateX < midValue) { return _indexOfTranslateX(translateX, start, mid, getX); } else if (translateX > midValue) { return _indexOfTranslateX(translateX, mid, end, getX); } else { return mid; } } double getBuyX(int position) => position * mBuyPointWidth!; double getSellX(int position) => position * mSellPointWidth! + mDrawWidth; getTextPainter(String text) => TextPainter( text: TextSpan( text: "$text", style: TextStyle(color: chartColors.defaultTextColor, fontSize: 10), ), textDirection: TextDirection.ltr, ); double getBottomTextY(double textHeight) => (mPaddingBottom - textHeight) / 2 + mDrawHeight; double getY(double volume) => mDrawHeight - (mDrawHeight) * volume / mMaxVolume!; @override bool shouldRepaint(DepthChartPainter oldDelegate) { // return oldDelegate.mBuyData != mBuyData || // oldDelegate.mSellData != mSellData || // oldDelegate.isLongPress != isLongPress || // oldDelegate.pressOffset != pressOffset; return true; } } class _PopupPainter { ///setting final double space = 3.5; final double padding = 8.0; late final TextPainter pricePaint; late final TextPainter amountPaint; late final ChartColors chartColors; ///getter double get width => max(pricePaint.width, amountPaint.width) + 2 * padding; double get height => pricePaint.height + amountPaint.height + space + 2 * padding; _PopupPainter({ required DepthChartTranslations chartTranslations, required ChartColors chartColors, required String price, required String amount, }) { this.chartColors = chartColors; this.pricePaint = _getTextPainter(chartTranslations.price, price); this.amountPaint = _getTextPainter(chartTranslations.amount, amount); this.pricePaint.layout(); this.amountPaint.layout(); } void paint(Canvas canvas, Offset offset) { pricePaint.paint(canvas, offset + Offset(padding, padding)); amountPaint.paint( canvas, offset + Offset(padding, pricePaint.height + space + padding)); } TextPainter _getTextPainter(String label, String content) { return TextPainter( text: TextSpan( text: "$label: ", style: TextStyle( color: this.chartColors.infoWindowTitleColor, fontSize: 10), children: [ TextSpan( text: content, style: TextStyle( color: this.chartColors.infoWindowNormalColor, fontSize: 10), ), ], ), textDirection: TextDirection.ltr, ); } }