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

558 lines
17 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线宽度不传则自动计算
final bool isDark; // 深色模式
final double scrollOffset; // 滚动偏移(像素)
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<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,
this.isDark = false,
this.scrollOffset = 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 = 20.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);
// 裁剪绘图区域防止K线超出边界
canvas.save();
canvas.clipRect(Rect.fromLTRB(leftPadding, 0, size.width - rightPadding, size.height));
// 绘制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;
// 应用滚动偏移scrollOffset为正时K线向左移动
final x = leftPadding + i * actualCandleWidth + actualCandleWidth / 2 - scrollOffset;
// 跳过不在可见区域的K线优化性能
if (x < leftPadding - actualCandleWidth || x > size.width - rightPadding + actualCandleWidth) {
continue;
}
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,
);
}
}
canvas.restore();
// 绘制MA线在裁剪区域内
canvas.save();
canvas.clipRect(Rect.fromLTRB(leftPadding, 0, size.width - rightPadding, size.height));
if (maData != null) {
int colorIndex = 0;
for (final entry in maData!.entries) {
_drawLine(
canvas,
entry.value,
_maColors[colorIndex % _maColors.length],
leftPadding,
actualCandleWidth,
priceToY,
scrollOffset,
);
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,
scrollOffset,
);
colorIndex++;
}
}
// 绘制BOLL线
if (bollData != null) {
_drawLine(canvas, bollData!['middle']!, _bollMiddleColor, leftPadding, actualCandleWidth, priceToY, scrollOffset);
_drawLine(canvas, bollData!['upper']!, _bollUpperColor, leftPadding, actualCandleWidth, priceToY, scrollOffset, isDashed: true);
_drawLine(canvas, bollData!['lower']!, _bollLowerColor, leftPadding, actualCandleWidth, priceToY, scrollOffset, isDashed: true);
}
canvas.restore();
// 绘制十字线
if (crossLineIndex >= 0 && crossLineIndex < klines.length) {
_drawCrossLine(canvas, size, crossLineIndex, leftPadding, actualCandleWidth, priceToY, scrollOffset);
}
// 绘制时间轴
_drawTimeAxis(canvas, size, leftPadding, rightPadding, bottomPadding, actualCandleWidth, scrollOffset);
// 绘制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 _drawTimeAxis(
Canvas canvas,
Size size,
double leftPadding,
double rightPadding,
double bottomPadding,
double candleWidth,
double scrollOffset,
) {
if (klines.isEmpty) return;
final textPainter = TextPainter(textDirection: ui.TextDirection.ltr);
final drawableWidth = size.width - leftPadding - rightPadding;
// 根据K线宽度动态计算时间标签间隔
// 目标标签之间至少间隔60像素
final minLabelSpacing = 60.0;
final klinePerLabel = (minLabelSpacing / candleWidth).ceil();
// 确保间隔是合理的数字5, 10, 15, 20, 30, 60等
final intervals = [5, 10, 15, 20, 30, 60, 120, 240];
int labelInterval = intervals.firstWhere(
(i) => i >= klinePerLabel,
orElse: () => klinePerLabel,
);
// 计算可见区域的起始和结束索引
final startIndex = (scrollOffset / candleWidth).floor();
final endIndex = ((scrollOffset + drawableWidth) / candleWidth).ceil();
// 找到第一个应该显示标签的索引(对齐到间隔)
final firstLabelIndex = ((startIndex / labelInterval).ceil()) * labelInterval;
final y = size.height - bottomPadding + 4;
for (int i = firstLabelIndex; i <= endIndex && i < klines.length; i += labelInterval) {
if (i < 0) continue;
final x = leftPadding + i * candleWidth + candleWidth / 2 - scrollOffset;
// 检查是否在可见区域内
if (x < leftPadding - 20 || x > size.width - rightPadding + 20) continue;
final time = klines[i].time;
final timeStr = _formatTimeLabel(time);
textPainter.text = TextSpan(
text: timeStr,
style: TextStyle(color: _textColor, fontSize: 9),
);
textPainter.layout();
// 居中绘制时间标签
final textX = x - textPainter.width / 2;
// 确保不超出边界
final clampedX = textX.clamp(leftPadding, size.width - rightPadding - textPainter.width);
textPainter.paint(canvas, Offset(clampedX, y));
}
}
String _formatTimeLabel(DateTime time) {
// 转换为本地时间
final localTime = time.toLocal();
// 根据时间显示不同格式
// 如果是新的一天,显示日期;否则显示时间
final hour = localTime.hour.toString().padLeft(2, '0');
final minute = localTime.minute.toString().padLeft(2, '0');
if (localTime.hour == 0 && localTime.minute == 0) {
// 新的一天,显示月/日
return '${localTime.month}/${localTime.day}';
}
return '$hour:$minute';
}
void _drawLine(
Canvas canvas,
List<double?> data,
Color color,
double leftPadding,
double candleWidth,
double Function(double) priceToY,
double scrollOffset, {
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 - scrollOffset;
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,
double scrollOffset,
) {
final paint = Paint()
..color = _crossLineColor
..strokeWidth = 0.5;
final x = leftPadding + index * candleWidth + candleWidth / 2 - scrollOffset;
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.scrollOffset != scrollOffset;
}
}