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

438 lines
12 KiB
Dart

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;
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,
});
@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;
final padding = priceRange * 0.1;
minPrice -= padding;
maxPrice += padding;
final adjustedRange = maxPrice - minPrice;
// Y坐标转换函数
double priceToY(double price) {
return topPadding + ((maxPrice - price) / adjustedRange) * chartHeight;
}
// 绘制网格和价格标签
_drawGrid(canvas, size, minPrice, maxPrice, leftPadding, rightPadding, topPadding, chartHeight);
// 计算K线宽度
final candleWidth = chartWidth / klines.length;
final bodyWidth = math.max(candleWidth * 0.7, 2.0);
final gap = candleWidth * 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 * candleWidth + candleWidth / 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,
candleWidth,
priceToY,
);
colorIndex++;
}
}
// 绘制EMA线
if (emaData != null) {
int colorIndex = 0;
for (final entry in emaData!.entries) {
_drawLine(
canvas,
entry.value,
_maColors[colorIndex % _maColors.length],
leftPadding,
candleWidth,
priceToY,
);
colorIndex++;
}
}
// 绘制BOLL线
if (bollData != null) {
_drawLine(canvas, bollData!['middle']!, _bollMiddleColor, leftPadding, candleWidth, priceToY);
_drawLine(canvas, bollData!['upper']!, _bollUpperColor, leftPadding, candleWidth, priceToY, isDashed: true);
_drawLine(canvas, bollData!['lower']!, _bollLowerColor, leftPadding, candleWidth, priceToY, isDashed: true);
}
// 绘制十字线
if (crossLineIndex >= 0 && crossLineIndex < klines.length) {
_drawCrossLine(canvas, size, crossLineIndex, leftPadding, candleWidth, 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;
if (maData != null) {
int colorIndex = 0;
for (final entry in maData!.entries) {
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();
textPainter.paint(canvas, Offset(x, y));
x += textPainter.width + 8;
}
colorIndex++;
}
}
if (emaData != null) {
int colorIndex = 0;
for (final entry in emaData!.entries) {
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();
textPainter.paint(canvas, Offset(x, y));
x += textPainter.width + 8;
}
colorIndex++;
}
}
if (bollData != null) {
final middle = bollData!['middle']?.lastWhere((v) => v != null, orElse: () => null);
final upper = bollData!['upper']?.lastWhere((v) => v != null, orElse: () => null);
final lower = bollData!['lower']?.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();
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;
}
}