depth_chart.dart 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519
  1. import 'dart:math';
  2. import 'package:flutter/material.dart';
  3. import 'package:k_chart_plus/chart_translations.dart';
  4. import 'package:k_chart_plus/k_chart_plus.dart';
  5. class DepthChart extends StatefulWidget {
  6. final List<DepthEntity> bids, asks;
  7. final int baseUnit;
  8. final int quoteUnit;
  9. final Offset offset;
  10. final ChartColors chartColors;
  11. final DepthChartTranslations chartTranslations;
  12. /// 全量数据的最大累计量,缩放时 Y 轴范围不变
  13. double get fullMaxVolume {
  14. if (bids.isEmpty && asks.isEmpty) return 0;
  15. double maxVol = 0;
  16. if (bids.isNotEmpty) maxVol = max(maxVol, bids.first.vol);
  17. if (asks.isNotEmpty) maxVol = max(maxVol, asks.last.vol);
  18. return maxVol * 1.05;
  19. }
  20. DepthChart(
  21. this.bids,
  22. this.asks,
  23. this.chartColors, {
  24. this.baseUnit = 2,
  25. this.quoteUnit = 6,
  26. this.offset = const Offset(10, 10),
  27. this.chartTranslations = const DepthChartTranslations(),
  28. });
  29. @override
  30. _DepthChartState createState() => _DepthChartState();
  31. }
  32. class _DepthChartState extends State<DepthChart> {
  33. Offset? pressOffset;
  34. bool isLongPress = false;
  35. double _scale = 1.0; // 缩放倍数:1.0=全量,放大时缩小价格可视范围
  36. double _lastScale = 1.0;
  37. /// 根据缩放截取买卖盘的可视价格范围。
  38. /// 放大时只显示中间价附近的档位,但保留累计量(不从 0 开始)。
  39. List<DepthEntity> _visibleBids() {
  40. if (_scale <= 1.0 || widget.bids.length <= 2) return widget.bids;
  41. final count = (widget.bids.length / _scale).ceil().clamp(2, widget.bids.length);
  42. // 买盘:取末尾 count 个(靠近中间价),保留原始累计量
  43. return widget.bids.sublist(widget.bids.length - count);
  44. }
  45. List<DepthEntity> _visibleAsks() {
  46. if (_scale <= 1.0 || widget.asks.length <= 2) return widget.asks;
  47. final count = (widget.asks.length / _scale).ceil().clamp(2, widget.asks.length);
  48. // 卖盘:取前 count 个(靠近中间价),保留原始累计量
  49. return widget.asks.sublist(0, count);
  50. }
  51. @override
  52. Widget build(BuildContext context) {
  53. return GestureDetector(
  54. onScaleStart: (_) => _lastScale = _scale,
  55. onScaleUpdate: (details) {
  56. if (details.scale != 1.0) {
  57. setState(() {
  58. _scale = (_lastScale * details.scale).clamp(1.0, 10.0);
  59. });
  60. }
  61. },
  62. onLongPressStart: (details) {
  63. pressOffset = details.localPosition;
  64. isLongPress = true;
  65. setState(() {});
  66. },
  67. onLongPressMoveUpdate: (details) {
  68. pressOffset = details.localPosition;
  69. isLongPress = true;
  70. setState(() {});
  71. },
  72. onLongPressEnd: (details) {
  73. pressOffset = null;
  74. isLongPress = false;
  75. setState(() {});
  76. },
  77. child: CustomPaint(
  78. size: Size(double.infinity, double.infinity),
  79. painter: DepthChartPainter(
  80. _visibleBids(),
  81. _visibleAsks(),
  82. pressOffset,
  83. isLongPress,
  84. widget.baseUnit,
  85. widget.quoteUnit,
  86. widget.chartColors,
  87. widget.offset,
  88. widget.chartTranslations,
  89. fixedMaxVolume: widget.fullMaxVolume,
  90. ),
  91. ),
  92. );
  93. }
  94. }
  95. class DepthChartPainter extends CustomPainter {
  96. //Buy//Sell
  97. List<DepthEntity>? mBuyData, mSellData;
  98. Offset? pressOffset;
  99. bool isLongPress;
  100. int baseUnit;
  101. int quoteUnit;
  102. ChartColors chartColors;
  103. double mPaddingBottom = 32.0;
  104. double mWidth = 0.0, mDrawHeight = 0.0, mDrawWidth = 0.0;
  105. double? mBuyPointWidth, mSellPointWidth;
  106. Offset offset;
  107. DepthChartTranslations chartTranslations;
  108. //最大的委托量(fixedMaxVolume 用于缩放时固定 Y 轴范围)
  109. double? mMaxVolume, mMultiple;
  110. double? fixedMaxVolume;
  111. //右侧绘制个数
  112. int mLineCount = 4;
  113. Path? mBuyPath, mSellPath;
  114. //买卖出区域边线绘制画笔 //买卖出取悦绘制画笔
  115. Paint? mBuyLinePaint,
  116. mSellLinePaint,
  117. mBuyPathPaint,
  118. mSellPathPaint,
  119. selectPaint,
  120. selectBorderPaint;
  121. DepthChartPainter(
  122. this.mBuyData,
  123. this.mSellData,
  124. this.pressOffset,
  125. this.isLongPress,
  126. this.baseUnit,
  127. this.quoteUnit,
  128. this.chartColors,
  129. this.offset,
  130. this.chartTranslations, {
  131. this.fixedMaxVolume,
  132. }) {
  133. mBuyLinePaint ??= Paint()
  134. ..isAntiAlias = true
  135. ..color = this.chartColors.depthBuyColor
  136. ..style = PaintingStyle.stroke
  137. ..strokeWidth = 1.5;
  138. mSellLinePaint ??= Paint()
  139. ..isAntiAlias = true
  140. ..color = this.chartColors.depthSellColor
  141. ..style = PaintingStyle.stroke
  142. ..strokeWidth = 1.5;
  143. // 渐变 Paint 在 paint() 里按实际尺寸重建,这里仅占位
  144. mBuyPathPaint ??= Paint()..isAntiAlias = true;
  145. mSellPathPaint ??= Paint()..isAntiAlias = true;
  146. mBuyPath ??= Path();
  147. mSellPath ??= Path();
  148. init();
  149. }
  150. void init() {
  151. if (mBuyData == null ||
  152. mBuyData!.isEmpty ||
  153. mSellData == null ||
  154. mSellData!.isEmpty) return;
  155. if (fixedMaxVolume != null && fixedMaxVolume! > 0) {
  156. // 缩放时使用固定的 Y 轴范围,保持曲线形状一致
  157. mMaxVolume = fixedMaxVolume;
  158. } else {
  159. mMaxVolume = mBuyData![0].vol;
  160. mMaxVolume = max(mMaxVolume!, mSellData!.last.vol);
  161. mMaxVolume = mMaxVolume! * 1.05;
  162. }
  163. mMultiple = mMaxVolume! / mLineCount;
  164. selectPaint = Paint()
  165. ..isAntiAlias = true
  166. ..color = chartColors.selectFillColor;
  167. selectBorderPaint = Paint()
  168. ..isAntiAlias = true
  169. ..color = chartColors.selectBorderColor
  170. ..style = PaintingStyle.stroke
  171. ..strokeWidth = 0.4;
  172. }
  173. @override
  174. void paint(Canvas canvas, Size size) {
  175. if (mBuyData == null ||
  176. mSellData == null ||
  177. mBuyData!.isEmpty ||
  178. mSellData!.isEmpty) return;
  179. mWidth = size.width;
  180. mDrawWidth = mWidth / 2;
  181. mDrawHeight = size.height - mPaddingBottom;
  182. // 用实际高度创建垂直渐变(顶部 0.5 → 底部 0.08),匹配原型设计
  183. final bidGradient = LinearGradient(
  184. begin: Alignment.topCenter,
  185. end: Alignment.bottomCenter,
  186. colors: [
  187. chartColors.depthBuyColor.withOpacity(0.5),
  188. chartColors.depthBuyColor.withOpacity(0.08),
  189. ],
  190. );
  191. final askGradient = LinearGradient(
  192. begin: Alignment.topCenter,
  193. end: Alignment.bottomCenter,
  194. colors: [
  195. chartColors.depthSellColor.withOpacity(0.5),
  196. chartColors.depthSellColor.withOpacity(0.08),
  197. ],
  198. );
  199. mBuyPathPaint = Paint()
  200. ..isAntiAlias = true
  201. ..shader = bidGradient.createShader(
  202. Rect.fromLTWH(0, 0, mDrawWidth, mDrawHeight));
  203. mSellPathPaint = Paint()
  204. ..isAntiAlias = true
  205. ..shader = askGradient.createShader(
  206. Rect.fromLTWH(mDrawWidth, 0, mDrawWidth, mDrawHeight));
  207. canvas.save();
  208. drawBuy(canvas);
  209. drawSell(canvas);
  210. drawText(canvas);
  211. canvas.restore();
  212. }
  213. void drawBuy(Canvas canvas) {
  214. mBuyPointWidth =
  215. (mDrawWidth / (mBuyData!.length - 1 == 0 ? 1 : mBuyData!.length - 1));
  216. mBuyPath!.reset();
  217. double x;
  218. double y;
  219. for (int i = 0; i < mBuyData!.length; i++) {
  220. if (i == 0) {
  221. mBuyPath!.moveTo(0, getY(mBuyData![0].vol));
  222. }
  223. x = mBuyPointWidth! * i;
  224. y = getY(mBuyData![i].vol);
  225. if (i >= 1) {
  226. canvas.drawLine(
  227. Offset(mBuyPointWidth! * (i - 1), getY(mBuyData![i - 1].vol)),
  228. Offset(x, y),
  229. mBuyLinePaint!);
  230. }
  231. if (i != mBuyData!.length - 1) {
  232. mBuyPath!.quadraticBezierTo(
  233. x, y, mBuyPointWidth! * (i + 1), getY(mBuyData![i + 1].vol));
  234. } else {
  235. if (i == 0) {
  236. mBuyPath!.lineTo(mDrawWidth, y);
  237. mBuyPath!.lineTo(mDrawWidth, mDrawHeight);
  238. mBuyPath!.lineTo(0, mDrawHeight);
  239. } else {
  240. mBuyPath!.quadraticBezierTo(x, y, x, mDrawHeight);
  241. mBuyPath!.quadraticBezierTo(x, mDrawHeight, 0, mDrawHeight);
  242. }
  243. mBuyPath!.close();
  244. }
  245. }
  246. canvas.drawPath(mBuyPath!, mBuyPathPaint!);
  247. }
  248. void drawSell(Canvas canvas) {
  249. mSellPointWidth =
  250. (mDrawWidth / (mSellData!.length - 1 == 0 ? 1 : mSellData!.length - 1));
  251. mSellPath!.reset();
  252. double x;
  253. double y;
  254. for (int i = 0; i < mSellData!.length; i++) {
  255. if (i == 0) {
  256. mSellPath!.moveTo(mDrawWidth, getY(mSellData![0].vol));
  257. }
  258. x = (mSellPointWidth! * i) + mDrawWidth;
  259. y = getY(mSellData![i].vol);
  260. if (i >= 1) {
  261. canvas.drawLine(
  262. Offset((mSellPointWidth! * (i - 1)) + mDrawWidth,
  263. getY(mSellData![i - 1].vol)),
  264. Offset(x, y),
  265. mSellLinePaint!);
  266. }
  267. if (i != mSellData!.length - 1) {
  268. mSellPath!.quadraticBezierTo(
  269. x,
  270. y,
  271. (mSellPointWidth! * (i + 1)) + mDrawWidth,
  272. getY(mSellData![i + 1].vol));
  273. } else {
  274. if (i == 0) {
  275. mSellPath!.lineTo(mWidth, y);
  276. mSellPath!.lineTo(mWidth, mDrawHeight);
  277. mSellPath!.lineTo(mDrawWidth, mDrawHeight);
  278. } else {
  279. mSellPath!.quadraticBezierTo(mWidth, y, x, mDrawHeight);
  280. mSellPath!.quadraticBezierTo(x, mDrawHeight, mDrawWidth, mDrawHeight);
  281. }
  282. mSellPath!.close();
  283. }
  284. }
  285. canvas.drawPath(mSellPath!, mSellPathPaint!);
  286. }
  287. // int? mLastPosition;
  288. void drawText(Canvas canvas) {
  289. double value;
  290. String str;
  291. for (int j = 0; j < mLineCount; j++) {
  292. value = mMaxVolume! - mMultiple! * j;
  293. str = value.toStringAsFixed(baseUnit);
  294. var tp = getTextPainter(str);
  295. tp.layout();
  296. tp.paint(
  297. canvas,
  298. Offset(
  299. mWidth - tp.width, mDrawHeight / mLineCount * j + tp.height / 2));
  300. }
  301. var startText = mBuyData!.first.price.toStringAsFixed(quoteUnit);
  302. TextPainter startTP = getTextPainter(startText);
  303. startTP.layout();
  304. startTP.paint(canvas, Offset(0, getBottomTextY(startTP.height)));
  305. double centerPrice = (mBuyData!.last.price + mSellData!.first.price) / 2;
  306. var center = centerPrice.toStringAsFixed(quoteUnit);
  307. TextPainter centerTP = getTextPainter(center);
  308. centerTP.layout();
  309. centerTP.paint(
  310. canvas,
  311. Offset(
  312. mDrawWidth - centerTP.width / 2, getBottomTextY(centerTP.height)));
  313. var endText = mSellData!.last.price.toStringAsFixed(quoteUnit);
  314. TextPainter endTP = getTextPainter(endText);
  315. endTP.layout();
  316. endTP.paint(
  317. canvas, Offset(mWidth - endTP.width, getBottomTextY(endTP.height)));
  318. var leftHalfText =
  319. ((mBuyData!.first.price + centerPrice) / 2).toStringAsFixed(quoteUnit);
  320. TextPainter leftHalfTP = getTextPainter(leftHalfText);
  321. leftHalfTP.layout();
  322. leftHalfTP.paint(
  323. canvas,
  324. Offset((mDrawWidth - leftHalfTP.width) / 2,
  325. getBottomTextY(leftHalfTP.height)));
  326. var rightHalfText =
  327. ((mSellData!.last.price + centerPrice) / 2).toStringAsFixed(quoteUnit);
  328. TextPainter rightHalfTP = getTextPainter(rightHalfText);
  329. rightHalfTP.layout();
  330. rightHalfTP.paint(
  331. canvas,
  332. Offset((mDrawWidth + mWidth - rightHalfTP.width) / 2,
  333. getBottomTextY(rightHalfTP.height)));
  334. if (isLongPress == true) {
  335. if (pressOffset!.dx <= mDrawWidth) {
  336. int index = _indexOfTranslateX(
  337. pressOffset!.dx, 0, mBuyData!.length - 1, getBuyX);
  338. drawSelectView(canvas, index, true);
  339. } else {
  340. int index = _indexOfTranslateX(
  341. pressOffset!.dx, 0, mSellData!.length - 1, getSellX);
  342. drawSelectView(canvas, index, false);
  343. }
  344. }
  345. }
  346. void drawSelectView(Canvas canvas, int index, bool isLeft) {
  347. DepthEntity entity = isLeft ? mBuyData![index] : mSellData![index];
  348. double dx = isLeft ? getBuyX(index) : getSellX(index);
  349. double dy = getY(entity.vol);
  350. double radius = 8.0;
  351. if (dx < mDrawWidth) {
  352. canvas.drawCircle(Offset(dx, dy), radius / 3,
  353. mBuyLinePaint!..style = PaintingStyle.fill);
  354. canvas.drawCircle(
  355. Offset(dx, dy), radius, mBuyLinePaint!..style = PaintingStyle.stroke);
  356. } else {
  357. canvas.drawCircle(Offset(dx, dy), radius / 3,
  358. mSellLinePaint!..style = PaintingStyle.fill);
  359. canvas.drawCircle(Offset(dx, dy), radius,
  360. mSellLinePaint!..style = PaintingStyle.stroke);
  361. }
  362. ///draw popup info
  363. ///
  364. _PopupPainter popupPainter = _PopupPainter(
  365. chartTranslations: this.chartTranslations,
  366. chartColors: this.chartColors,
  367. price: entity.price.toStringAsFixed(quoteUnit),
  368. amount: entity.vol.toStringAsFixed(baseUnit),
  369. );
  370. dx = dx < mDrawWidth ? dx + offset.dx : dx - offset.dx - popupPainter.width;
  371. dy = dy < mDrawHeight / 2
  372. ? dy + offset.dy
  373. : dy - offset.dy - popupPainter.height;
  374. Rect rect = Rect.fromLTWH(dx, dy, popupPainter.width, popupPainter.height);
  375. RRect boxRect = RRect.fromRectAndRadius(rect, Radius.circular(2.5));
  376. canvas.drawRRect(boxRect, selectPaint!);
  377. canvas.drawRRect(boxRect, selectBorderPaint!);
  378. popupPainter.paint(canvas, rect.topLeft);
  379. }
  380. ///Binary search for current value: index
  381. int _indexOfTranslateX(double translateX, int start, int end, Function getX) {
  382. if (end == start || end == -1) {
  383. return start;
  384. }
  385. if (end - start == 1) {
  386. double startValue = getX(start);
  387. double endValue = getX(end);
  388. return (translateX - startValue).abs() < (translateX - endValue).abs()
  389. ? start
  390. : end;
  391. }
  392. int mid = start + (end - start) ~/ 2;
  393. double midValue = getX(mid);
  394. if (translateX < midValue) {
  395. return _indexOfTranslateX(translateX, start, mid, getX);
  396. } else if (translateX > midValue) {
  397. return _indexOfTranslateX(translateX, mid, end, getX);
  398. } else {
  399. return mid;
  400. }
  401. }
  402. double getBuyX(int position) => position * mBuyPointWidth!;
  403. double getSellX(int position) => position * mSellPointWidth! + mDrawWidth;
  404. getTextPainter(String text) => TextPainter(
  405. text: TextSpan(
  406. text: "$text",
  407. style: TextStyle(color: chartColors.defaultTextColor, fontSize: 10),
  408. ),
  409. textDirection: TextDirection.ltr,
  410. );
  411. double getBottomTextY(double textHeight) =>
  412. (mPaddingBottom - textHeight) / 2 + mDrawHeight;
  413. double getY(double volume) =>
  414. mDrawHeight - (mDrawHeight) * volume / mMaxVolume!;
  415. @override
  416. bool shouldRepaint(DepthChartPainter oldDelegate) {
  417. // return oldDelegate.mBuyData != mBuyData ||
  418. // oldDelegate.mSellData != mSellData ||
  419. // oldDelegate.isLongPress != isLongPress ||
  420. // oldDelegate.pressOffset != pressOffset;
  421. return true;
  422. }
  423. }
  424. class _PopupPainter {
  425. ///setting
  426. final double space = 3.5;
  427. final double padding = 8.0;
  428. late final TextPainter pricePaint;
  429. late final TextPainter amountPaint;
  430. late final ChartColors chartColors;
  431. ///getter
  432. double get width => max(pricePaint.width, amountPaint.width) + 2 * padding;
  433. double get height =>
  434. pricePaint.height + amountPaint.height + space + 2 * padding;
  435. _PopupPainter({
  436. required DepthChartTranslations chartTranslations,
  437. required ChartColors chartColors,
  438. required String price,
  439. required String amount,
  440. }) {
  441. this.chartColors = chartColors;
  442. this.pricePaint = _getTextPainter(chartTranslations.price, price);
  443. this.amountPaint = _getTextPainter(chartTranslations.amount, amount);
  444. this.pricePaint.layout();
  445. this.amountPaint.layout();
  446. }
  447. void paint(Canvas canvas, Offset offset) {
  448. pricePaint.paint(canvas, offset + Offset(padding, padding));
  449. amountPaint.paint(
  450. canvas, offset + Offset(padding, pricePaint.height + space + padding));
  451. }
  452. TextPainter _getTextPainter(String label, String content) {
  453. return TextPainter(
  454. text: TextSpan(
  455. text: "$label: ",
  456. style: TextStyle(
  457. color: this.chartColors.infoWindowTitleColor, fontSize: 10),
  458. children: [
  459. TextSpan(
  460. text: content,
  461. style: TextStyle(
  462. color: this.chartColors.infoWindowNormalColor, fontSize: 10),
  463. ),
  464. ],
  465. ),
  466. textDirection: TextDirection.ltr,
  467. );
  468. }
  469. }