449 lines
13 KiB
Dart
449 lines
13 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;
|
||
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;
|
||
}
|
||
}
|