import 'dart:math' as math; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import '../../../domain/entities/kline.dart'; /// K线主图绘制器 class KlinePainter extends CustomPainter { final List klines; final Map>? maData; final Map>? emaData; final Map>? bollData; final int crossLineIndex; final double? candleWidth; // 可选的K线宽度,不传则自动计算 final bool isDark; // 深色模式 final double leftOffset; // K线稀疏时的左侧偏移,用于右对齐显示 static const Color _green = Color(0xFF10B981); static const Color _red = Color(0xFFEF4444); // 根据主题动态获取颜色 Color get _gridColor => isDark ? const Color(0xFF374151) : const Color(0xFFE5E7EB); Color get _textColor => isDark ? const Color(0xFF9CA3AF) : const Color(0xFF6B7280); Color get _crossLineColor => isDark ? const Color(0xFF6B7280) : const Color(0xFF9CA3AF); // MA线颜色 static const List _maColors = [ Color(0xFFFF9800), // MA5 橙色 Color(0xFF2196F3), // MA10 蓝色 Color(0xFF9C27B0), // MA20 紫色 Color(0xFF4CAF50), // MA60 绿色 ]; // BOLL颜色 static const Color _bollMiddleColor = Color(0xFF2196F3); static const Color _bollUpperColor = Color(0xFFFF5722); static const Color _bollLowerColor = Color(0xFF4CAF50); KlinePainter({ required this.klines, this.maData, this.emaData, this.bollData, this.crossLineIndex = -1, this.candleWidth, this.isDark = false, this.leftOffset = 0.0, }); @override void paint(Canvas canvas, Size size) { if (klines.isEmpty) return; // 绘图参数 const leftPadding = 8.0; const rightPadding = 50.0; const topPadding = 20.0; const bottomPadding = 8.0; final chartWidth = size.width - leftPadding - rightPadding; final chartHeight = size.height - topPadding - bottomPadding; // 计算价格范围 double minPrice = double.infinity; double maxPrice = double.negativeInfinity; for (final kline in klines) { final low = double.tryParse(kline.low) ?? 0; final high = double.tryParse(kline.high) ?? 0; if (low < minPrice) minPrice = low; if (high > maxPrice) maxPrice = high; } // 考虑MA/EMA/BOLL线的范围 if (maData != null) { for (final values in maData!.values) { for (final v in values) { if (v != null) { if (v < minPrice) minPrice = v; if (v > maxPrice) maxPrice = v; } } } } if (emaData != null) { for (final values in emaData!.values) { for (final v in values) { if (v != null) { if (v < minPrice) minPrice = v; if (v > maxPrice) maxPrice = v; } } } } if (bollData != null) { for (final values in bollData!.values) { for (final v in values) { if (v != null) { if (v < minPrice) minPrice = v; if (v > maxPrice) maxPrice = v; } } } } // 添加上下留白 final priceRange = maxPrice - minPrice; // 避免 priceRange 为 0 导致 NaN final safePriceRange = priceRange > 0 ? priceRange : (maxPrice > 0 ? maxPrice * 0.1 : 1.0); final padding = safePriceRange * 0.1; minPrice -= padding; maxPrice += padding; final adjustedRange = maxPrice - minPrice; // Y坐标转换函数 double priceToY(double price) { if (adjustedRange <= 0) return topPadding + chartHeight / 2; return topPadding + ((maxPrice - price) / adjustedRange) * chartHeight; } // 绘制网格和价格标签 _drawGrid(canvas, size, minPrice, maxPrice, leftPadding, rightPadding, topPadding, chartHeight); // 计算K线宽度 - 使用传入的宽度或自动计算 final actualCandleWidth = candleWidth ?? (chartWidth / klines.length); final bodyWidth = math.max(actualCandleWidth * 0.7, 2.0); final gap = actualCandleWidth * 0.15; // 绘制K线 for (int i = 0; i < klines.length; i++) { final kline = klines[i]; final open = double.tryParse(kline.open) ?? 0; final close = double.tryParse(kline.close) ?? 0; final high = double.tryParse(kline.high) ?? 0; final low = double.tryParse(kline.low) ?? 0; final isUp = close >= open; final color = isUp ? _green : _red; final paint = Paint()..color = color; final x = leftPadding + leftOffset + i * actualCandleWidth + actualCandleWidth / 2; final yOpen = priceToY(open); final yClose = priceToY(close); final yHigh = priceToY(high); final yLow = priceToY(low); // 绘制影线 canvas.drawLine( Offset(x, yHigh), Offset(x, yLow), paint..strokeWidth = 1, ); // 绘制实体 final bodyTop = math.min(yOpen, yClose); final bodyBottom = math.max(yOpen, yClose); final actualBodyBottom = bodyBottom - bodyTop < 1 ? bodyTop + 1 : bodyBottom; if (isUp) { // 阳线:空心或实心 canvas.drawRect( Rect.fromLTRB(x - bodyWidth / 2, bodyTop, x + bodyWidth / 2, actualBodyBottom), paint..style = PaintingStyle.fill, ); } else { // 阴线:实心 canvas.drawRect( Rect.fromLTRB(x - bodyWidth / 2, bodyTop, x + bodyWidth / 2, actualBodyBottom), paint..style = PaintingStyle.fill, ); } } // 绘制MA线 if (maData != null) { int colorIndex = 0; for (final entry in maData!.entries) { _drawLine( canvas, entry.value, _maColors[colorIndex % _maColors.length], leftPadding + leftOffset, actualCandleWidth, priceToY, ); colorIndex++; } } // 绘制EMA线 if (emaData != null) { int colorIndex = 0; for (final entry in emaData!.entries) { _drawLine( canvas, entry.value, _maColors[colorIndex % _maColors.length], leftPadding + leftOffset, actualCandleWidth, priceToY, ); colorIndex++; } } // 绘制BOLL线 if (bollData != null) { _drawLine(canvas, bollData!['middle']!, _bollMiddleColor, leftPadding + leftOffset, actualCandleWidth, priceToY); _drawLine(canvas, bollData!['upper']!, _bollUpperColor, leftPadding + leftOffset, actualCandleWidth, priceToY, isDashed: true); _drawLine(canvas, bollData!['lower']!, _bollLowerColor, leftPadding + leftOffset, actualCandleWidth, priceToY, isDashed: true); } // 绘制十字线 if (crossLineIndex >= 0 && crossLineIndex < klines.length) { _drawCrossLine(canvas, size, crossLineIndex, leftPadding + leftOffset, actualCandleWidth, priceToY); } // 绘制MA图例 _drawLegend(canvas, size, leftPadding, topPadding); } void _drawGrid( Canvas canvas, Size size, double minPrice, double maxPrice, double leftPadding, double rightPadding, double topPadding, double chartHeight, ) { final gridPaint = Paint() ..color = _gridColor ..strokeWidth = 0.5; final textPainter = TextPainter(textDirection: ui.TextDirection.ltr); // 绘制水平网格线和价格标签 const gridLines = 4; final priceStep = (maxPrice - minPrice) / gridLines; for (int i = 0; i <= gridLines; i++) { final y = topPadding + (chartHeight / gridLines) * i; // 网格线 canvas.drawLine( Offset(leftPadding, y), Offset(size.width - rightPadding, y), gridPaint, ); // 价格标签 final price = maxPrice - priceStep * i; textPainter.text = TextSpan( text: _formatPrice(price), style: TextStyle(color: _textColor, fontSize: 9), ); textPainter.layout(); textPainter.paint(canvas, Offset(size.width - rightPadding + 4, y - textPainter.height / 2)); } } void _drawLine( Canvas canvas, List data, Color color, double leftPadding, double candleWidth, double Function(double) priceToY, { bool isDashed = false, }) { final paint = Paint() ..color = color ..strokeWidth = 1 ..style = PaintingStyle.stroke; final path = Path(); bool started = false; for (int i = 0; i < data.length; i++) { if (data[i] != null) { final x = leftPadding + i * candleWidth + candleWidth / 2; final y = priceToY(data[i]!); if (!started) { path.moveTo(x, y); started = true; } else { path.lineTo(x, y); } } } if (isDashed) { // 绘制虚线 final dashPath = _createDashedPath(path, 4, 2); canvas.drawPath(dashPath, paint); } else { canvas.drawPath(path, paint); } } Path _createDashedPath(Path source, double dashLength, double gapLength) { final path = Path(); for (final metric in source.computeMetrics()) { double distance = 0.0; while (distance < metric.length) { final next = distance + dashLength; path.addPath( metric.extractPath(distance, math.min(next, metric.length)), Offset.zero, ); distance = next + gapLength; } } return path; } void _drawCrossLine( Canvas canvas, Size size, int index, double leftPadding, double candleWidth, double Function(double) priceToY, ) { final paint = Paint() ..color = _crossLineColor ..strokeWidth = 0.5; final x = leftPadding + index * candleWidth + candleWidth / 2; final kline = klines[index]; final close = double.tryParse(kline.close) ?? 0; final y = priceToY(close); // 垂直线 canvas.drawLine( Offset(x, 0), Offset(x, size.height), paint, ); // 水平线 canvas.drawLine( Offset(0, y), Offset(size.width, y), paint, ); // 价格标签 final textPainter = TextPainter( text: const TextSpan( text: '', style: TextStyle(color: Colors.white, fontSize: 9), ), textDirection: ui.TextDirection.ltr, ); textPainter.text = TextSpan( text: _formatPrice(close), style: const TextStyle(color: Colors.white, fontSize: 9), ); textPainter.layout(); final labelRect = RRect.fromRectAndRadius( Rect.fromLTWH( size.width - 50, y - textPainter.height / 2 - 2, textPainter.width + 8, textPainter.height + 4, ), const Radius.circular(2), ); canvas.drawRRect(labelRect, Paint()..color = _crossLineColor); textPainter.paint( canvas, Offset(size.width - 50 + 4, y - textPainter.height / 2), ); } void _drawLegend(Canvas canvas, Size size, double leftPadding, double topPadding) { final textPainter = TextPainter(textDirection: ui.TextDirection.ltr); double x = leftPadding; final y = 4.0; final maxX = size.width - 60; // 留出右侧价格标签空间 if (maData != null) { int colorIndex = 0; for (final entry in maData!.entries) { if (x > maxX) break; // 超出边界则停止绘制 final lastValue = entry.value.lastWhere((v) => v != null, orElse: () => null); if (lastValue != null) { textPainter.text = TextSpan( text: 'MA${entry.key}: ${_formatPrice(lastValue)}', style: TextStyle(color: _maColors[colorIndex % _maColors.length], fontSize: 9), ); textPainter.layout(); if (x + textPainter.width > maxX) break; textPainter.paint(canvas, Offset(x, y)); x += textPainter.width + 8; } colorIndex++; } } if (emaData != null) { int colorIndex = 0; for (final entry in emaData!.entries) { if (x > maxX) break; final lastValue = entry.value.lastWhere((v) => v != null, orElse: () => null); if (lastValue != null) { textPainter.text = TextSpan( text: 'EMA${entry.key}: ${_formatPrice(lastValue)}', style: TextStyle(color: _maColors[colorIndex % _maColors.length], fontSize: 9), ); textPainter.layout(); if (x + textPainter.width > maxX) break; textPainter.paint(canvas, Offset(x, y)); x += textPainter.width + 8; } colorIndex++; } } if (bollData != null && x <= maxX) { final middle = bollData!['middle']?.lastWhere((v) => v != null, orElse: () => null); if (middle != null) { textPainter.text = TextSpan( text: 'BOLL: ${_formatPrice(middle)}', style: const TextStyle(color: _bollMiddleColor, fontSize: 9), ); textPainter.layout(); if (x + textPainter.width <= maxX) { textPainter.paint(canvas, Offset(x, y)); x += textPainter.width + 8; } } } } String _formatPrice(double price) { if (price >= 1) return price.toStringAsFixed(4); if (price >= 0.0001) return price.toStringAsFixed(6); return price.toStringAsExponential(2); } @override bool shouldRepaint(covariant KlinePainter oldDelegate) { return oldDelegate.klines != klines || oldDelegate.maData != maData || oldDelegate.emaData != emaData || oldDelegate.bollData != bollData || oldDelegate.crossLineIndex != crossLineIndex || oldDelegate.candleWidth != candleWidth || oldDelegate.isDark != isDark || oldDelegate.leftOffset != leftOffset; } }