| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519 |
- 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<DepthEntity> 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<DepthChart> {
- Offset? pressOffset;
- bool isLongPress = false;
- double _scale = 1.0; // 缩放倍数:1.0=全量,放大时缩小价格可视范围
- double _lastScale = 1.0;
- /// 根据缩放截取买卖盘的可视价格范围。
- /// 放大时只显示中间价附近的档位,但保留累计量(不从 0 开始)。
- List<DepthEntity> _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<DepthEntity> _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<DepthEntity>? 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,
- );
- }
- }
|