rwadurian/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_painter.dart

449 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Kline> klines;
final Map<int, List<double?>>? maData;
final Map<int, List<double?>>? emaData;
final Map<String, List<double?>>? bollData;
final int crossLineIndex;
final double? candleWidth; // 可选的K线宽度不传则自动计算
static const Color _green = Color(0xFF10B981);
static const Color _red = Color(0xFFEF4444);
static const Color _gridColor = Color(0xFFE5E7EB);
static const Color _textColor = Color(0xFF6B7280);
static const Color _crossLineColor = Color(0xFF9CA3AF);
// MA线颜色
static const List<Color> _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,
});
@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 + 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,
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,
actualCandleWidth,
priceToY,
);
colorIndex++;
}
}
// 绘制BOLL线
if (bollData != null) {
_drawLine(canvas, bollData!['middle']!, _bollMiddleColor, leftPadding, actualCandleWidth, priceToY);
_drawLine(canvas, bollData!['upper']!, _bollUpperColor, leftPadding, actualCandleWidth, priceToY, isDashed: true);
_drawLine(canvas, bollData!['lower']!, _bollLowerColor, leftPadding, actualCandleWidth, priceToY, isDashed: true);
}
// 绘制十字线
if (crossLineIndex >= 0 && crossLineIndex < klines.length) {
_drawCrossLine(canvas, size, crossLineIndex, leftPadding, 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: const TextStyle(color: _textColor, fontSize: 9),
);
textPainter.layout();
textPainter.paint(canvas, Offset(size.width - rightPadding + 4, y - textPainter.height / 2));
}
}
void _drawLine(
Canvas canvas,
List<double?> 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: TextSpan(
text: _formatPrice(close),
style: const TextStyle(color: Colors.white, fontSize: 9),
),
textDirection: ui.TextDirection.ltr,
);
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;
}
}