438 lines
12 KiB
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;
|
|
}
|
|
}
|