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 e10db74d..1fa13388 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 @@ -42,17 +42,18 @@ class _KlineChartWidgetState extends State { static const Color _grayText = Color(0xFF6B7280); static const Color _borderGray = Color(0xFFE5E7EB); - // 缩放和滑动状态 - double _scale = 1.0; - double _prevScale = 1.0; - double _offsetX = 0.0; - double _startOffsetX = 0.0; + // 缩放和滑动状态 - 基于candleWidth的缩放模式(大厂标准做法) + double _candleWidth = 8.0; // 单根K线宽度(像素) + double _prevCandleWidth = 8.0; + double _scrollX = 0.0; // 滚动偏移(像素) + double _startScrollX = 0.0; Offset? _startFocalPoint; + bool _isScaling = false; + double _chartWidth = 0.0; // 缓存图表宽度 - // 可见K线数量范围 - int _visibleCandleCount = 60; - static const int _minVisibleCandles = 20; - static const int _maxVisibleCandles = 200; + // K线宽度范围 + static const double _minCandleWidth = 3.0; + static const double _maxCandleWidth = 30.0; // 指标选择 int _selectedMainIndicator = 0; // 0: MA, 1: EMA, 2: BOLL @@ -72,13 +73,13 @@ class _KlineChartWidgetState extends State { } void _scrollToEnd() { - if (widget.klines.isEmpty) return; - final totalCandles = widget.klines.length; - if (totalCandles > _visibleCandleCount) { - setState(() { - _offsetX = (totalCandles - _visibleCandleCount).toDouble(); - }); - } + if (widget.klines.isEmpty || _chartWidth == 0) return; + // 滚动到最右边(显示最新数据) + final totalWidth = widget.klines.length * _candleWidth; + final maxScroll = math.max(0.0, totalWidth - _chartWidth); + setState(() { + _scrollX = maxScroll; + }); } @override @@ -140,8 +141,7 @@ class _KlineChartWidgetState extends State { icon: const Icon(Icons.refresh, color: _orange), onPressed: () { setState(() { - _scale = 1.0; - _visibleCandleCount = 60; + _candleWidth = 8.0; _scrollToEnd(); }); }, @@ -282,6 +282,9 @@ class _KlineChartWidgetState extends State { final chartHeight = height ?? constraints.maxHeight; final chartWidth = constraints.maxWidth; + // 缓存图表宽度用于滚动计算 + _chartWidth = chartWidth; + if (widget.klines.isEmpty) { return Center( child: Column( @@ -295,8 +298,8 @@ class _KlineChartWidgetState extends State { ); } - // 计算可见数据 - final visibleData = _getVisibleData(); + // 计算可见数据(基于像素滚动) + final visibleData = _getVisibleData(chartWidth); // 计算指标数据 final processor = KlineDataProcessor(widget.klines); @@ -307,15 +310,15 @@ class _KlineChartWidgetState extends State { final bollData = processor.calculateBOLL(); final emaData = processor.calculateEMA([5, 10, 20]); - // 计算各区域高度 + // 计算各区域高度 - 全屏模式给主图更多空间 final mainChartHeight = widget.showVolume - ? (widget.isFullScreen ? chartHeight * 0.5 : chartHeight * 0.6) - : chartHeight * 0.7; + ? (widget.isFullScreen ? chartHeight * 0.65 : chartHeight * 0.6) + : (widget.isFullScreen ? chartHeight * 0.75 : chartHeight * 0.7); final volumeHeight = widget.showVolume - ? (widget.isFullScreen ? chartHeight * 0.15 : chartHeight * 0.2) + ? (widget.isFullScreen ? chartHeight * 0.12 : chartHeight * 0.2) : 0.0; final indicatorHeight = widget.isFullScreen - ? chartHeight * 0.25 + ? (widget.showVolume ? chartHeight * 0.23 : chartHeight * 0.25) : (widget.showVolume ? chartHeight * 0.2 : chartHeight * 0.3); return Column( @@ -333,6 +336,7 @@ class _KlineChartWidgetState extends State { 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, ), ), // 十字线信息 @@ -371,6 +375,7 @@ class _KlineChartWidgetState extends State { painter: KlineVolumePainter( klines: visibleData.klines, crossLineIndex: _showCrossLine ? _crossLineIndex - visibleData.startIndex : -1, + candleWidth: visibleData.candleWidth, ), ), ), @@ -385,6 +390,7 @@ class _KlineChartWidgetState extends State { 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, ), ), ), @@ -432,39 +438,49 @@ class _KlineChartWidgetState extends State { ); } - // 手势处理 + // 手势处理 - 大厂标准做法:基于像素的滚动和缩放 void _onScaleStart(ScaleStartDetails details) { - _prevScale = _scale; - _startOffsetX = _offsetX; + _prevCandleWidth = _candleWidth; + _startScrollX = _scrollX; _startFocalPoint = details.focalPoint; + // 检测是否为两指缩放(pointerCount > 1表示多指触摸) + _isScaling = details.pointerCount > 1; } void _onScaleUpdate(ScaleUpdateDetails details) { setState(() { - // 缩放 - if (details.scale != 1.0) { - final newScale = (_prevScale * details.scale).clamp(0.5, 3.0); - _scale = newScale; + // 两指缩放:调整K线宽度,围绕焦点缩放 + if (_isScaling || (details.scale - 1.0).abs() > 0.01) { + _isScaling = true; + final newCandleWidth = (_prevCandleWidth * details.scale).clamp(_minCandleWidth, _maxCandleWidth); - // 根据缩放调整可见K线数量 - _visibleCandleCount = (60 / _scale).round().clamp(_minVisibleCandles, _maxVisibleCandles); + // 围绕焦点缩放:调整滚动位置以保持焦点处的K线位置不变 + if (_startFocalPoint != null && _chartWidth > 0) { + final focalRatio = _startFocalPoint!.dx / _chartWidth; + final oldFocalIndex = (_startScrollX + _startFocalPoint!.dx) / _prevCandleWidth; + final newFocalX = oldFocalIndex * newCandleWidth; + _scrollX = (newFocalX - focalRatio * _chartWidth).clamp( + 0.0, + math.max(0.0, widget.klines.length * newCandleWidth - _chartWidth), + ); + } + + _candleWidth = newCandleWidth; } - // 平移 - if (_startFocalPoint != null) { + // 单指平移(非缩放模式时)- 直接使用像素位移 + if (!_isScaling && _startFocalPoint != null) { final dx = details.focalPoint.dx - _startFocalPoint!.dx; - // 每移动一定像素对应移动一根K线 - final candleShift = -dx / 10; - _offsetX = (_startOffsetX + candleShift).clamp( - 0.0, - math.max(0.0, (widget.klines.length - _visibleCandleCount).toDouble()), - ); + final totalWidth = widget.klines.length * _candleWidth; + final maxScroll = math.max(0.0, totalWidth - _chartWidth); + _scrollX = (_startScrollX - dx).clamp(0.0, maxScroll); } }); } void _onScaleEnd(ScaleEndDetails details) { _startFocalPoint = null; + _isScaling = false; } void _onLongPressStart(LongPressStartDetails details) { @@ -484,40 +500,46 @@ class _KlineChartWidgetState extends State { } void _updateCrossLine(Offset position) { - if (widget.klines.isEmpty) return; + if (widget.klines.isEmpty || _chartWidth == 0) return; setState(() { _showCrossLine = true; - // 计算对应的K线索引 - final visibleData = _getVisibleData(); - if (visibleData.klines.isEmpty) return; - final candleWidth = (MediaQuery.of(context).size.width - 32) / visibleData.klines.length; - final localIndex = (position.dx / candleWidth).floor().clamp(0, visibleData.klines.length - 1); - _crossLineIndex = visibleData.startIndex + localIndex; + // 计算对应的K线索引(基于像素位置) + final absoluteX = _scrollX + position.dx; + final index = (absoluteX / _candleWidth).floor().clamp(0, widget.klines.length - 1); + _crossLineIndex = index; }); } - // 数据处理 - _VisibleData _getVisibleData() { - if (widget.klines.isEmpty) { - return _VisibleData(klines: [], startIndex: 0); + // 数据处理 - 基于像素滚动计算可见数据 + _VisibleData _getVisibleData(double chartWidth) { + if (widget.klines.isEmpty || chartWidth == 0) { + return _VisibleData(klines: [], startIndex: 0, candleWidth: _candleWidth); } - final int maxStart = math.max(0, widget.klines.length - _visibleCandleCount); - final int startIndex = _offsetX.floor().clamp(0, maxStart); - final int endIndex = math.min(startIndex + _visibleCandleCount, widget.klines.length); + // 根据滚动位置计算可见的K线范围 + final int startIndex = (_scrollX / _candleWidth).floor().clamp(0, widget.klines.length - 1); + final int visibleCount = (chartWidth / _candleWidth).ceil() + 1; // +1 确保边缘K线可见 + final int endIndex = math.min(startIndex + visibleCount, 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 + _visibleCandleCount, entry.value.length); + final endIndex = math.min(startIndex + visibleCount, entry.value.length); if (startIndex < entry.value.length) { result[entry.key] = entry.value.sublist(startIndex, endIndex); } @@ -526,9 +548,10 @@ class _KlineChartWidgetState extends State { } Map> _getVisibleBollData(Map> fullData, int startIndex) { + final visibleCount = _getVisibleCount(); final result = >{}; for (final entry in fullData.entries) { - final endIndex = math.min(startIndex + _visibleCandleCount, entry.value.length); + final endIndex = math.min(startIndex + visibleCount, entry.value.length); if (startIndex < entry.value.length) { result[entry.key] = entry.value.sublist(startIndex, endIndex); } @@ -537,9 +560,10 @@ class _KlineChartWidgetState extends State { } Map> _getVisibleMacdData(Map> fullData, int startIndex) { + final visibleCount = _getVisibleCount(); final result = >{}; for (final entry in fullData.entries) { - final endIndex = math.min(startIndex + _visibleCandleCount, entry.value.length); + final endIndex = math.min(startIndex + visibleCount, entry.value.length); if (startIndex < entry.value.length) { result[entry.key] = entry.value.sublist(startIndex, endIndex); } @@ -548,9 +572,10 @@ class _KlineChartWidgetState extends State { } Map> _getVisibleKdjData(Map> fullData, int startIndex) { + final visibleCount = _getVisibleCount(); final result = >{}; for (final entry in fullData.entries) { - final endIndex = math.min(startIndex + _visibleCandleCount, entry.value.length); + final endIndex = math.min(startIndex + visibleCount, entry.value.length); if (startIndex < entry.value.length) { result[entry.key] = entry.value.sublist(startIndex, endIndex); } @@ -559,7 +584,8 @@ class _KlineChartWidgetState extends State { } List _getVisibleRsiData(List fullData, int startIndex) { - final endIndex = math.min(startIndex + _visibleCandleCount, fullData.length); + final visibleCount = _getVisibleCount(); + final endIndex = math.min(startIndex + visibleCount, fullData.length); if (startIndex < fullData.length) { return fullData.sublist(startIndex, endIndex); } @@ -686,6 +712,7 @@ class _KlineChartWidgetState extends State { class _VisibleData { final List klines; final int startIndex; + final double candleWidth; - _VisibleData({required this.klines, required this.startIndex}); + _VisibleData({required this.klines, required this.startIndex, required this.candleWidth}); } diff --git a/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_indicator_painter.dart b/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_indicator_painter.dart index a74254b7..41049a16 100644 --- a/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_indicator_painter.dart +++ b/frontend/mining-app/lib/presentation/widgets/kline_chart/kline_indicator_painter.dart @@ -9,6 +9,7 @@ class KlineIndicatorPainter extends CustomPainter { final Map>? kdjData; final List? rsiData; final int crossLineIndex; + final double? candleWidth; static const Color _green = Color(0xFF10B981); static const Color _red = Color(0xFFEF4444); @@ -34,6 +35,7 @@ class KlineIndicatorPainter extends CustomPainter { this.kdjData, this.rsiData, this.crossLineIndex = -1, + this.candleWidth, }); @override @@ -72,8 +74,8 @@ class KlineIndicatorPainter extends CustomPainter { if (crossLineIndex >= 0) { final dataLength = _getDataLength(); if (dataLength > 0 && crossLineIndex < dataLength) { - final candleWidth = chartWidth / dataLength; - final x = leftPadding + crossLineIndex * candleWidth + candleWidth / 2; + final actualCandleWidth = candleWidth ?? (chartWidth / dataLength); + final x = leftPadding + crossLineIndex * actualCandleWidth + actualCandleWidth / 2; canvas.drawLine( Offset(x, 0), Offset(x, size.height), @@ -146,14 +148,14 @@ class KlineIndicatorPainter extends CustomPainter { ); // 绘制MACD柱状图 - final candleWidth = chartWidth / macd.length; - final barWidth = math.max(candleWidth * 0.6, 2.0); + final actualCandleWidth = candleWidth ?? (chartWidth / macd.length); + final barWidth = math.max(actualCandleWidth * 0.6, 2.0); for (int i = 0; i < macd.length; i++) { if (macd[i] == null) continue; final value = macd[i]!; - final x = leftPadding + i * candleWidth + candleWidth / 2; + final x = leftPadding + i * actualCandleWidth + actualCandleWidth / 2; final y = valueToY(value); final color = value >= 0 ? _red : _green; @@ -173,10 +175,10 @@ class KlineIndicatorPainter extends CustomPainter { } // 绘制DIF线 - _drawIndicatorLine(canvas, dif, _difColor, leftPadding, candleWidth, valueToY); + _drawIndicatorLine(canvas, dif, _difColor, leftPadding, actualCandleWidth, valueToY); // 绘制DEA线 - _drawIndicatorLine(canvas, dea, _deaColor, leftPadding, candleWidth, valueToY); + _drawIndicatorLine(canvas, dea, _deaColor, leftPadding, actualCandleWidth, valueToY); // 绘制图例 _drawMACDLegend(canvas, size, leftPadding, dif, dea, macd); @@ -234,16 +236,16 @@ class KlineIndicatorPainter extends CustomPainter { } } - final candleWidth = chartWidth / k.length; + final actualCandleWidth = candleWidth ?? (chartWidth / k.length); // 绘制K线 - _drawIndicatorLine(canvas, k, _kColor, leftPadding, candleWidth, valueToY); + _drawIndicatorLine(canvas, k, _kColor, leftPadding, actualCandleWidth, valueToY); // 绘制D线 - _drawIndicatorLine(canvas, d, _dColor, leftPadding, candleWidth, valueToY); + _drawIndicatorLine(canvas, d, _dColor, leftPadding, actualCandleWidth, valueToY); // 绘制J线 - _drawIndicatorLine(canvas, j, _jColor, leftPadding, candleWidth, valueToY); + _drawIndicatorLine(canvas, j, _jColor, leftPadding, actualCandleWidth, valueToY); // 绘制图例 _drawKDJLegend(canvas, size, leftPadding, k, d, j); @@ -301,10 +303,10 @@ class KlineIndicatorPainter extends CustomPainter { oversoldPaint, ); - final candleWidth = chartWidth / rsiData!.length; + final actualCandleWidth = candleWidth ?? (chartWidth / rsiData!.length); // 绘制RSI线 - _drawIndicatorLine(canvas, rsiData!, _rsiColor, leftPadding, candleWidth, valueToY); + _drawIndicatorLine(canvas, rsiData!, _rsiColor, leftPadding, actualCandleWidth, valueToY); // 绘制图例 _drawRSILegend(canvas, size, leftPadding, rsiData!); @@ -497,6 +499,7 @@ class KlineIndicatorPainter extends CustomPainter { oldDelegate.macdData != macdData || oldDelegate.kdjData != kdjData || oldDelegate.rsiData != rsiData || - oldDelegate.crossLineIndex != crossLineIndex; + oldDelegate.crossLineIndex != crossLineIndex || + oldDelegate.candleWidth != 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 ceaf1ce0..a2456b41 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 @@ -10,6 +10,7 @@ class KlinePainter extends CustomPainter { final Map>? emaData; final Map>? bollData; final int crossLineIndex; + final double? candleWidth; // 可选的K线宽度,不传则自动计算 static const Color _green = Color(0xFF10B981); static const Color _red = Color(0xFFEF4444); @@ -36,6 +37,7 @@ class KlinePainter extends CustomPainter { this.emaData, this.bollData, this.crossLineIndex = -1, + this.candleWidth, }); @override @@ -111,10 +113,10 @@ class KlinePainter extends CustomPainter { // 绘制网格和价格标签 _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线宽度 - 使用传入的宽度或自动计算 + 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++) { @@ -128,7 +130,7 @@ class KlinePainter extends CustomPainter { final color = isUp ? _green : _red; final paint = Paint()..color = color; - final x = leftPadding + i * candleWidth + candleWidth / 2; + final x = leftPadding + i * actualCandleWidth + actualCandleWidth / 2; final yOpen = priceToY(open); final yClose = priceToY(close); final yHigh = priceToY(high); @@ -170,7 +172,7 @@ class KlinePainter extends CustomPainter { entry.value, _maColors[colorIndex % _maColors.length], leftPadding, - candleWidth, + actualCandleWidth, priceToY, ); colorIndex++; @@ -186,7 +188,7 @@ class KlinePainter extends CustomPainter { entry.value, _maColors[colorIndex % _maColors.length], leftPadding, - candleWidth, + actualCandleWidth, priceToY, ); colorIndex++; @@ -195,14 +197,14 @@ class KlinePainter extends CustomPainter { // 绘制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); + _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, candleWidth, priceToY); + _drawCrossLine(canvas, size, crossLineIndex, leftPadding, actualCandleWidth, priceToY); } // 绘制MA图例 @@ -432,6 +434,7 @@ class KlinePainter extends CustomPainter { oldDelegate.maData != maData || oldDelegate.emaData != emaData || oldDelegate.bollData != bollData || - oldDelegate.crossLineIndex != crossLineIndex; + oldDelegate.crossLineIndex != crossLineIndex || + oldDelegate.candleWidth != candleWidth; } } 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 6dea2b7c..c9f92fe6 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 @@ -7,6 +7,7 @@ import '../../../domain/entities/kline.dart'; class KlineVolumePainter extends CustomPainter { final List klines; final int crossLineIndex; + final double? candleWidth; static const Color _green = Color(0xFF10B981); static const Color _red = Color(0xFFEF4444); @@ -16,6 +17,7 @@ class KlineVolumePainter extends CustomPainter { KlineVolumePainter({ required this.klines, this.crossLineIndex = -1, + this.candleWidth, }); @override @@ -72,8 +74,8 @@ class KlineVolumePainter extends CustomPainter { maxVolText.paint(canvas, Offset(size.width - rightPadding + 4, topPadding)); // 绘制成交量柱 - final candleWidth = chartWidth / klines.length; - final barWidth = math.max(candleWidth * 0.7, 2.0); + final actualCandleWidth = candleWidth ?? (chartWidth / klines.length); + final barWidth = math.max(actualCandleWidth * 0.7, 2.0); for (int i = 0; i < klines.length; i++) { final kline = klines[i]; @@ -84,7 +86,7 @@ class KlineVolumePainter extends CustomPainter { final isUp = close >= open; final color = isUp ? _green : _red; - final x = leftPadding + i * candleWidth + candleWidth / 2; + final x = leftPadding + i * actualCandleWidth + actualCandleWidth / 2; final barHeight = (volume / maxVolume) * chartHeight; final y = size.height - bottomPadding - barHeight; @@ -99,7 +101,7 @@ class KlineVolumePainter extends CustomPainter { // 绘制十字线 if (crossLineIndex >= 0 && crossLineIndex < klines.length) { - final x = leftPadding + crossLineIndex * candleWidth + candleWidth / 2; + final x = leftPadding + crossLineIndex * actualCandleWidth + actualCandleWidth / 2; canvas.drawLine( Offset(x, 0), Offset(x, size.height), @@ -131,6 +133,8 @@ class KlineVolumePainter extends CustomPainter { @override bool shouldRepaint(covariant KlineVolumePainter oldDelegate) { - return oldDelegate.klines != klines || oldDelegate.crossLineIndex != crossLineIndex; + return oldDelegate.klines != klines || + oldDelegate.crossLineIndex != crossLineIndex || + oldDelegate.candleWidth != candleWidth; } }