From f55fb13f2688cb91c4f94293e2a3a0c91875e5c7 Mon Sep 17 00:00:00 2001 From: hailin Date: Mon, 19 Jan 2026 21:43:45 -0800 Subject: [PATCH] feat(kline): implement true pan effect with scrollOffset - Added scrollOffset parameter to KlinePainter and KlineVolumePainter - Painters now draw all klines and apply scrollOffset for positioning - Added canvas clipping to prevent drawing outside chart bounds - Removed _getVisibleData and related helper methods (no longer needed) - scrollOffset directly controls the visual position of all klines Co-Authored-By: Claude Opus 4.5 --- .../kline_chart/kline_chart_widget.dart | 194 +++++------------- .../widgets/kline_chart/kline_painter.dart | 46 ++++- .../kline_chart/kline_volume_painter.dart | 21 +- 3 files changed, 106 insertions(+), 155 deletions(-) diff --git a/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_chart_widget.dart b/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_chart_widget.dart index 49d9faa9..b9cddd03 100644 --- a/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_chart_widget.dart +++ b/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_chart_widget.dart @@ -94,23 +94,29 @@ class _KlineChartWidgetState extends State { /// /// 计算逻辑: /// - K线从左边开始排列 - /// - 数据不够时(总宽度 <= 屏幕宽度的一半),不滚动,有多少显示多少 + /// - 数据不够时,不滚动,有多少显示多少 /// - 数据量大时,计算滚动位置让最新K线在屏幕中心 void _scrollToCenter() { if (widget.klines.isEmpty || _chartWidth == 0) return; - // 计算屏幕中心到左边缘能显示多少根K线 - final int halfScreenCount = (_chartWidth / 2 / _candleWidth).ceil(); + // painter 内部的 padding 常量 + const leftPadding = 8.0; + const rightPadding = 50.0; + final drawableWidth = _chartWidth - leftPadding - rightPadding; - if (widget.klines.length <= halfScreenCount) { - // 数据不够半屏,不滚动,从左开始显示 + // 所有K线的总宽度 + final totalKlineWidth = widget.klines.length * _candleWidth; + + if (totalKlineWidth <= drawableWidth) { + // 数据不够填满可绘制区域,不滚动 _scrollX = 0; } else { // 数据量足够,滚动使最新K线在屏幕中心 - // startIndex = klines.length - halfScreenCount - // scrollX = startIndex * candleWidth - final int startIndex = widget.klines.length - halfScreenCount; - _scrollX = startIndex * _candleWidth; + // 最新K线的中心位置(从leftPadding开始)= (N-0.5) * candleWidth + // 目标:让这个位置对齐到 drawableWidth/2 + // scrollOffset = (N-0.5) * candleWidth - drawableWidth/2 + _scrollX = (widget.klines.length - 0.5) * _candleWidth - drawableWidth / 2; + _scrollX = math.max(0.0, _scrollX); } } @@ -353,9 +359,6 @@ class _KlineChartWidgetState extends State { ); } - // 计算可见数据(基于像素滚动) - final visibleData = _getVisibleData(chartWidth); - // 计算指标数据 final processor = KlineDataProcessor(widget.klines); final maData = processor.calculateMA([5, 10, 20, 60]); @@ -386,24 +389,24 @@ class _KlineChartWidgetState extends State { CustomPaint( size: Size(chartWidth, mainChartHeight), painter: KlinePainter( - klines: visibleData.klines, - maData: _selectedMainIndicator == 0 ? _getVisibleMAData(maData, visibleData.startIndex) : null, - emaData: _selectedMainIndicator == 1 ? _getVisibleMAData(emaData, visibleData.startIndex) : null, - bollData: _selectedMainIndicator == 2 ? _getVisibleBollData(bollData, visibleData.startIndex) : null, - crossLineIndex: _showCrossLine ? _crossLineIndex - visibleData.startIndex : -1, - candleWidth: visibleData.candleWidth, + klines: widget.klines, + maData: _selectedMainIndicator == 0 ? maData : null, + emaData: _selectedMainIndicator == 1 ? emaData : null, + bollData: _selectedMainIndicator == 2 ? bollData : null, + crossLineIndex: _showCrossLine ? _crossLineIndex : -1, + candleWidth: _candleWidth, isDark: AppColors.isDark(context), + scrollOffset: _scrollX, ), ), // 十字线信息 if (_showCrossLine && _crossLineIndex >= 0 && _crossLineIndex < widget.klines.length) - _buildCrossLineInfo(visibleData), + _buildCrossLineInfo(), // 当前价格标签 - if (visibleData.klines.isNotEmpty) + if (widget.klines.isNotEmpty) Positioned( right: 0, top: _calcPriceY( - visibleData, double.tryParse(widget.currentPrice) ?? 0, mainChartHeight, ), @@ -429,10 +432,11 @@ class _KlineChartWidgetState extends State { child: CustomPaint( size: Size(chartWidth, volumeHeight), painter: KlineVolumePainter( - klines: visibleData.klines, - crossLineIndex: _showCrossLine ? _crossLineIndex - visibleData.startIndex : -1, - candleWidth: visibleData.candleWidth, + klines: widget.klines, + crossLineIndex: _showCrossLine ? _crossLineIndex : -1, + candleWidth: _candleWidth, isDark: AppColors.isDark(context), + scrollOffset: _scrollX, ), ), ), @@ -443,11 +447,12 @@ class _KlineChartWidgetState extends State { // size: Size(chartWidth, indicatorHeight), // painter: KlineIndicatorPainter( // indicatorType: _selectedSubIndicator, - // macdData: _selectedSubIndicator == 0 ? _getVisibleMacdData(macdData, visibleData.startIndex) : null, - // kdjData: _selectedSubIndicator == 1 ? _getVisibleKdjData(kdjData, visibleData.startIndex) : null, - // rsiData: _selectedSubIndicator == 2 ? _getVisibleRsiData(rsiData, visibleData.startIndex) : null, - // crossLineIndex: _showCrossLine ? _crossLineIndex - visibleData.startIndex : -1, - // candleWidth: visibleData.candleWidth, + // macdData: _selectedSubIndicator == 0 ? macdData : null, + // kdjData: _selectedSubIndicator == 1 ? kdjData : null, + // rsiData: _selectedSubIndicator == 2 ? rsiData : null, + // crossLineIndex: _showCrossLine ? _crossLineIndex : -1, + // candleWidth: _candleWidth, + // scrollOffset: _scrollX, // ), // ), // ), @@ -505,6 +510,11 @@ class _KlineChartWidgetState extends State { void _onScaleUpdate(ScaleUpdateDetails details) { setState(() { + // painter 内部的 padding 常量 + const leftPadding = 8.0; + const rightPadding = 50.0; + final drawableWidth = _chartWidth - leftPadding - rightPadding; + // 两指缩放 if (_isScaling) { final newCandleWidth = (_prevCandleWidth * details.scale).clamp(_minCandleWidth, _maxCandleWidth); @@ -514,8 +524,8 @@ class _KlineChartWidgetState extends State { final oldFocalIndex = (_startScrollX + _startFocalPoint!.dx) / _prevCandleWidth; final newFocalX = oldFocalIndex * newCandleWidth; // 缩放后重新计算maxScroll - final int halfScreenCount = (_chartWidth / 2 / newCandleWidth).ceil(); - final double maxScroll = math.max(0.0, (widget.klines.length - halfScreenCount) * newCandleWidth); + final newDrawableWidth = _chartWidth - leftPadding - rightPadding; + final double maxScroll = math.max(0.0, (widget.klines.length - 0.5) * newCandleWidth - newDrawableWidth / 2); _scrollX = (newFocalX - focalRatio * _chartWidth).clamp(0.0, maxScroll); } @@ -525,13 +535,12 @@ class _KlineChartWidgetState extends State { // dx > 0 向右滑(看更早历史),dx < 0 向左滑(看更新数据) final dx = details.focalPoint.dx - _startFocalPoint!.dx; // 最大滚动位置:让最新K线在屏幕中心 - final int halfScreenCount = (_chartWidth / 2 / _candleWidth).ceil(); - final double maxScroll = math.max(0.0, (widget.klines.length - halfScreenCount) * _candleWidth); + final double maxScroll = math.max(0.0, (widget.klines.length - 0.5) * _candleWidth - drawableWidth / 2); // 向右滑 dx>0,scrollX 减小,显示更早的数据 _scrollX = (_startScrollX - dx).clamp(0.0, maxScroll); // DEBUG: 打印滚动信息 - debugPrint('Pan: dx=$dx, scrollX=$_scrollX, maxScroll=$maxScroll, startIndex=${(_scrollX / _candleWidth).floor()}'); + debugPrint('Pan: dx=$dx, scrollX=$_scrollX, maxScroll=$maxScroll'); } }); } @@ -564,106 +573,17 @@ class _KlineChartWidgetState extends State { _showCrossLine = true; // 计算对应的K线索引(基于像素位置) - final absoluteX = _scrollX + position.dx; - final index = (absoluteX / _candleWidth).floor().clamp(0, widget.klines.length - 1); + // position.dx 是相对于widget的位置 + // painter 中第i根K线的x坐标 = leftPadding + i * candleWidth + candleWidth/2 - scrollOffset + // 所以 i = (position.dx + scrollOffset - leftPadding - candleWidth/2) / candleWidth + // i = (position.dx + scrollOffset - leftPadding) / candleWidth - 0.5 + const leftPadding = 8.0; + final index = ((position.dx + _scrollX - leftPadding) / _candleWidth).floor().clamp(0, widget.klines.length - 1); _crossLineIndex = index; }); } - // 数据处理 - 计算要显示的K线数据 - _VisibleData _getVisibleData(double chartWidth) { - if (widget.klines.isEmpty || chartWidth == 0) { - return _VisibleData(klines: [], startIndex: 0, candleWidth: _candleWidth); - } - - // 计算屏幕能显示多少根K线 - final int fullScreenCount = (chartWidth / _candleWidth).ceil() + 1; - // 计算屏幕中心到左边缘能显示多少根K线 - final int halfScreenCount = (chartWidth / 2 / _candleWidth).ceil(); - - // 数据量不够半屏时,显示全部,从左开始 - if (widget.klines.length <= halfScreenCount) { - return _VisibleData( - klines: widget.klines, - startIndex: 0, - candleWidth: _candleWidth, - ); - } - - // 数据量足够时,根据 _scrollX 计算起始索引 - final int startIndex = (_scrollX / _candleWidth).floor().clamp(0, math.max(0, widget.klines.length - 1)); - final int endIndex = math.min(startIndex + fullScreenCount, widget.klines.length); - - return _VisibleData( - klines: widget.klines.sublist(startIndex, endIndex), - startIndex: startIndex, - candleWidth: _candleWidth, - ); - } - - int _getVisibleCount() { - if (_chartWidth == 0) return 60; - return (_chartWidth / _candleWidth).ceil() + 1; - } - - Map> _getVisibleMAData(Map> fullData, int startIndex) { - final visibleCount = _getVisibleCount(); - final result = >{}; - for (final entry in fullData.entries) { - final endIndex = math.min(startIndex + visibleCount, entry.value.length); - if (startIndex < entry.value.length) { - result[entry.key] = entry.value.sublist(startIndex, endIndex); - } - } - return result; - } - - Map> _getVisibleBollData(Map> fullData, int startIndex) { - final visibleCount = _getVisibleCount(); - final result = >{}; - for (final entry in fullData.entries) { - final endIndex = math.min(startIndex + visibleCount, entry.value.length); - if (startIndex < entry.value.length) { - result[entry.key] = entry.value.sublist(startIndex, endIndex); - } - } - return result; - } - - Map> _getVisibleMacdData(Map> fullData, int startIndex) { - final visibleCount = _getVisibleCount(); - final result = >{}; - for (final entry in fullData.entries) { - final endIndex = math.min(startIndex + visibleCount, entry.value.length); - if (startIndex < entry.value.length) { - result[entry.key] = entry.value.sublist(startIndex, endIndex); - } - } - return result; - } - - Map> _getVisibleKdjData(Map> fullData, int startIndex) { - final visibleCount = _getVisibleCount(); - final result = >{}; - for (final entry in fullData.entries) { - final endIndex = math.min(startIndex + visibleCount, entry.value.length); - if (startIndex < entry.value.length) { - result[entry.key] = entry.value.sublist(startIndex, endIndex); - } - } - return result; - } - - List _getVisibleRsiData(List fullData, int startIndex) { - final visibleCount = _getVisibleCount(); - final endIndex = math.min(startIndex + visibleCount, fullData.length); - if (startIndex < fullData.length) { - return fullData.sublist(startIndex, endIndex); - } - return []; - } - - Widget _buildCrossLineInfo(_VisibleData visibleData) { + Widget _buildCrossLineInfo() { if (_crossLineIndex < 0 || _crossLineIndex >= widget.klines.length) { return const SizedBox.shrink(); } @@ -759,12 +679,12 @@ class _KlineChartWidgetState extends State { return '${time.month}/${time.day} ${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; } - double _calcPriceY(_VisibleData visibleData, double price, double chartHeight) { - if (visibleData.klines.isEmpty) return chartHeight / 2; + double _calcPriceY(double price, double chartHeight) { + if (widget.klines.isEmpty) return chartHeight / 2; double minPrice = double.infinity; double maxPrice = double.negativeInfinity; - for (final kline in visibleData.klines) { + for (final kline in widget.klines) { final low = double.tryParse(kline.low) ?? 0; final high = double.tryParse(kline.high) ?? 0; if (low < minPrice) minPrice = low; @@ -785,11 +705,3 @@ class _KlineChartWidgetState extends State { return y.clamp(0.0, chartHeight - 20); } } - -class _VisibleData { - final List klines; - final int startIndex; - final double candleWidth; - - _VisibleData({required this.klines, required this.startIndex, required this.candleWidth}); -} diff --git a/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_painter.dart b/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_painter.dart index 666aae3a..54e6723a 100644 --- a/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_painter.dart +++ b/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_painter.dart @@ -12,6 +12,7 @@ class KlinePainter extends CustomPainter { 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); @@ -42,6 +43,7 @@ class KlinePainter extends CustomPainter { this.crossLineIndex = -1, this.candleWidth, this.isDark = false, + this.scrollOffset = 0.0, }); @override @@ -123,7 +125,10 @@ class KlinePainter extends CustomPainter { // 计算K线宽度 - 使用传入的宽度或自动计算 final actualCandleWidth = candleWidth ?? (chartWidth / klines.length); final bodyWidth = math.max(actualCandleWidth * 0.7, 2.0); - final gap = actualCandleWidth * 0.15; + + // 裁剪绘图区域,防止K线超出边界 + canvas.save(); + canvas.clipRect(Rect.fromLTRB(leftPadding, 0, size.width - rightPadding, size.height)); // 绘制K线 for (int i = 0; i < klines.length; i++) { @@ -137,7 +142,14 @@ class KlinePainter extends CustomPainter { final color = isUp ? _green : _red; final paint = Paint()..color = color; - final x = leftPadding + i * actualCandleWidth + actualCandleWidth / 2; + // 应用滚动偏移: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); @@ -170,7 +182,12 @@ class KlinePainter extends CustomPainter { } } - // 绘制MA线 + 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) { @@ -181,6 +198,7 @@ class KlinePainter extends CustomPainter { leftPadding, actualCandleWidth, priceToY, + scrollOffset, ); colorIndex++; } @@ -197,6 +215,7 @@ class KlinePainter extends CustomPainter { leftPadding, actualCandleWidth, priceToY, + scrollOffset, ); colorIndex++; } @@ -204,14 +223,16 @@ class KlinePainter extends CustomPainter { // 绘制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); + _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); + _drawCrossLine(canvas, size, crossLineIndex, leftPadding, actualCandleWidth, priceToY, scrollOffset); } // 绘制MA图例 @@ -265,7 +286,8 @@ class KlinePainter extends CustomPainter { Color color, double leftPadding, double candleWidth, - double Function(double) priceToY, { + double Function(double) priceToY, + double scrollOffset, { bool isDashed = false, }) { final paint = Paint() @@ -278,7 +300,7 @@ class KlinePainter extends CustomPainter { for (int i = 0; i < data.length; i++) { if (data[i] != null) { - final x = leftPadding + i * candleWidth + candleWidth / 2; + final x = leftPadding + i * candleWidth + candleWidth / 2 - scrollOffset; final y = priceToY(data[i]!); if (!started) { @@ -322,12 +344,13 @@ class KlinePainter extends CustomPainter { 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; + final x = leftPadding + index * candleWidth + candleWidth / 2 - scrollOffset; final kline = klines[index]; final close = double.tryParse(kline.close) ?? 0; final y = priceToY(close); @@ -452,6 +475,7 @@ class KlinePainter extends CustomPainter { oldDelegate.bollData != bollData || oldDelegate.crossLineIndex != crossLineIndex || oldDelegate.candleWidth != candleWidth || - oldDelegate.isDark != isDark; + oldDelegate.isDark != isDark || + oldDelegate.scrollOffset != scrollOffset; } } diff --git a/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_volume_painter.dart b/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_volume_painter.dart index f2155951..9e560d4e 100644 --- a/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_volume_painter.dart +++ b/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_volume_painter.dart @@ -9,6 +9,7 @@ class KlineVolumePainter extends CustomPainter { final int crossLineIndex; final double? candleWidth; final bool isDark; // 深色模式 + final double scrollOffset; // 滚动偏移(像素) static const Color _green = Color(0xFF10B981); static const Color _red = Color(0xFFEF4444); @@ -23,6 +24,7 @@ class KlineVolumePainter extends CustomPainter { this.crossLineIndex = -1, this.candleWidth, this.isDark = false, + this.scrollOffset = 0.0, }); @override @@ -82,6 +84,10 @@ class KlineVolumePainter extends CustomPainter { final actualCandleWidth = candleWidth ?? (chartWidth / klines.length); final barWidth = math.max(actualCandleWidth * 0.7, 2.0); + // 裁剪绘图区域 + canvas.save(); + canvas.clipRect(Rect.fromLTRB(leftPadding, 0, size.width - rightPadding, size.height)); + for (int i = 0; i < klines.length; i++) { final kline = klines[i]; final volume = double.tryParse(kline.volume) ?? 0; @@ -91,7 +97,13 @@ class KlineVolumePainter extends CustomPainter { final isUp = close >= open; final color = isUp ? _green : _red; - final x = leftPadding + i * actualCandleWidth + actualCandleWidth / 2; + final x = leftPadding + i * actualCandleWidth + actualCandleWidth / 2 - scrollOffset; + + // 跳过不在可见区域的柱子 + if (x < leftPadding - actualCandleWidth || x > size.width - rightPadding + actualCandleWidth) { + continue; + } + final barHeight = (volume / maxVolume) * chartHeight; final y = size.height - bottomPadding - barHeight; @@ -104,9 +116,11 @@ class KlineVolumePainter extends CustomPainter { ); } + canvas.restore(); + // 绘制十字线 if (crossLineIndex >= 0 && crossLineIndex < klines.length) { - final x = leftPadding + crossLineIndex * actualCandleWidth + actualCandleWidth / 2; + final x = leftPadding + crossLineIndex * actualCandleWidth + actualCandleWidth / 2 - scrollOffset; canvas.drawLine( Offset(x, 0), Offset(x, size.height), @@ -141,6 +155,7 @@ class KlineVolumePainter extends CustomPainter { return oldDelegate.klines != klines || oldDelegate.crossLineIndex != crossLineIndex || oldDelegate.candleWidth != candleWidth || - oldDelegate.isDark != isDark; + oldDelegate.isDark != isDark || + oldDelegate.scrollOffset != scrollOffset; } }