From bb032fb88fb29aa80ee868954ad77e275d46ffa6 Mon Sep 17 00:00:00 2001 From: behnam-deriv <133759298+behnam-deriv@users.noreply.github.com> Date: Tue, 10 Jun 2025 17:33:50 +0800 Subject: [PATCH 1/7] feate: add AutoIntervalWrapper --- AUTO_INTERVAL_USAGE.md | 219 ++++++++++++++++++ example/lib/main.dart | 123 ++++++++-- lib/deriv_chart.dart | 1 + .../auto_interval/auto_interval_wrapper.dart | 131 +++++++++++ .../auto_interval/zoom_level_observer.dart | 5 + lib/src/deriv_chart/chart/basic_chart.dart | 38 +-- lib/src/deriv_chart/chart/chart.dart | 26 ++- .../chart/x_axis/widgets/x_axis_base.dart | 6 + .../chart/x_axis/x_axis_model.dart | 21 +- lib/src/deriv_chart/deriv_chart.dart | 8 + lib/src/models/chart_axis_config.dart | 111 +++++++++ 11 files changed, 652 insertions(+), 37 deletions(-) create mode 100644 AUTO_INTERVAL_USAGE.md create mode 100644 lib/src/deriv_chart/chart/auto_interval/auto_interval_wrapper.dart create mode 100644 lib/src/deriv_chart/chart/auto_interval/zoom_level_observer.dart diff --git a/AUTO_INTERVAL_USAGE.md b/AUTO_INTERVAL_USAGE.md new file mode 100644 index 000000000..8d5c67f74 --- /dev/null +++ b/AUTO_INTERVAL_USAGE.md @@ -0,0 +1,219 @@ +# Auto Interval Wrapper Usage + +The `AutoIntervalWrapper` provides a simplified way to add automatic granularity switching to your charts based on zoom levels. It follows the same wrapper pattern as `GestureManager` and `XAxisWrapper`. + +**✨ Fully Self-Contained**: No separate manager classes needed - all logic is built directly into the wrapper! + +## Basic Usage + +### With Chart Widget + +```dart +import 'package:deriv_chart/deriv_chart.dart'; + +// Wrap your chart with AutoIntervalWrapper +AutoIntervalWrapper( + enabled: true, + granularity: currentGranularity, // in milliseconds + zoomRanges: const [ + AutoIntervalZoomRange( + granularity: 60000, // 1 minute + minPixelsPerInterval: 20, + maxPixelsPerInterval: 120, + optimalPixelsPerInterval: 40, + ), + AutoIntervalZoomRange( + granularity: 300000, // 5 minutes + minPixelsPerInterval: 20, + maxPixelsPerInterval: 120, + optimalPixelsPerInterval: 40, + ), + // Add more ranges as needed + ], + onGranularityChangeRequested: (int suggestedGranularity) { + // Handle granularity change request + print('Auto-interval suggests: ${suggestedGranularity}ms'); + + // Update your data and granularity + updateChartGranularity(suggestedGranularity ~/ 1000); // Convert to seconds + }, + child: Chart( + mainSeries: yourDataSeries, + granularity: currentGranularity, + // ... other chart properties + ), +) +``` + +### With DerivChart Widget + +```dart +AutoIntervalWrapper( + enabled: true, + granularity: currentGranularity, + zoomRanges: defaultAutoIntervalRanges, // Use predefined ranges + onGranularityChangeRequested: (int suggestedGranularity) { + // Handle the suggestion + final int suggestedGranularitySeconds = suggestedGranularity ~/ 1000; + + if (suggestedGranularitySeconds != currentGranularitySeconds) { + // Update granularity and fetch new data + fetchNewData(suggestedGranularitySeconds); + } + }, + child: DerivChart( + mainSeries: yourDataSeries, + granularity: currentGranularity, + activeSymbol: currentSymbol, + // ... other chart properties + ), +) +``` + +## Configuration + +### AutoIntervalZoomRange Parameters + +- `granularity`: The time interval in milliseconds (e.g., 60000 for 1-minute candles) +- `minPixelsPerInterval`: Minimum pixels per interval before switching to smaller granularity +- `maxPixelsPerInterval`: Maximum pixels per interval before switching to larger granularity +- `optimalPixelsPerInterval`: Optimal pixels per interval for this granularity (default: 40.0) + +### Pre-defined Ranges + +You can use the default auto-interval ranges: + +```dart +import 'package:deriv_chart/deriv_chart.dart'; + +// Uses the default trading timeframes configuration +zoomRanges: defaultAutoIntervalRanges, +``` + +The default ranges include common trading timeframes from 1 minute to 1 day. + +## Integration with State Management + +```dart +class ChartWidget extends StatefulWidget { + @override + _ChartWidgetState createState() => _ChartWidgetState(); +} + +class _ChartWidgetState extends State { + int currentGranularity = 300000; // 5 minutes + List chartData = []; + bool autoIntervalEnabled = true; + + @override + Widget build(BuildContext context) { + Widget chartContent = DerivChart( + mainSeries: DataSeries(chartData), + granularity: currentGranularity, + activeSymbol: 'EURUSD', + // ... other properties + ); + + // Conditionally wrap with auto-interval if enabled + if (autoIntervalEnabled) { + chartContent = AutoIntervalWrapper( + enabled: true, + granularity: currentGranularity, + onGranularityChangeRequested: _handleGranularityChange, + child: chartContent, + ); + } + + return chartContent; + } + + void _handleGranularityChange(int suggestedGranularity) { + final int suggestedSeconds = suggestedGranularity ~/ 1000; + + if (suggestedSeconds != currentGranularity ~/ 1000) { + setState(() { + currentGranularity = suggestedGranularity; + }); + + // Fetch new data with the suggested granularity + _fetchChartData(suggestedSeconds); + } + } + + Future _fetchChartData(int granularitySeconds) async { + // Your data fetching logic here + final newData = await fetchTickData(granularitySeconds); + setState(() { + chartData = newData; + }); + } +} +``` + +## Benefits + +1. **Ultra Simple**: Single wrapper widget with all logic built-in +2. **No Dependencies**: No manager classes or complex setup +3. **Consistent Pattern**: Follows the same pattern as other chart wrappers +4. **Minimal Code**: Fewer files and simpler architecture +5. **Easy Integration**: Works with both `Chart` and `DerivChart` widgets +6. **Better Performance**: Direct calculations without extra abstraction layers + +## Architecture + +### Simplified Design +``` +AutoIntervalWrapper (all-in-one) +├── ZoomLevelObserver implementation +├── Granularity calculation logic +├── State management (current/last suggested) +└── Provider for children +``` + +### What's Inside AutoIntervalWrapper +- **State Tracking**: Current granularity and last suggestion +- **Calculation Logic**: Optimal granularity based on zoom ranges +- **Observer Interface**: Implements `ZoomLevelObserver` directly +- **Provider Integration**: Exposes itself to chart components + +## Migration from Old Approach + +If you were using the old `ChartAxisConfig.autoIntervalEnabled` approach: + +### Before (Old Way) +```dart +Chart( + // ... properties + chartAxisConfig: ChartAxisConfig( + autoIntervalEnabled: true, + autoIntervalZoomRanges: yourRanges, + ), + onGranularityChangeRequested: handleChange, +) +``` + +### After (New Simplified Way) +```dart +AutoIntervalWrapper( + enabled: true, + granularity: currentGranularity, + zoomRanges: yourRanges, + onGranularityChangeRequested: handleChange, + child: Chart( + // ... properties + chartAxisConfig: ChartAxisConfig( + // autoIntervalEnabled no longer needed + ), + ), +) +``` + +## Technical Notes + +- **No AutoIntervalManager**: All logic is directly in the wrapper state +- **Stateful Widget**: Manages granularity state and calculations internally +- **Provider Pattern**: Exposes `ZoomLevelObserver` interface to children +- **Automatic Cleanup**: No manual disposal needed - Flutter handles it +- **Reactive Updates**: Responds to granularity changes via `didUpdateWidget` + +The wrapper approach provides maximum simplicity with minimal overhead! diff --git a/example/lib/main.dart b/example/lib/main.dart index e97a55d72..df4ac6897 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -80,13 +80,16 @@ class _FullscreenChartState extends State { static const String defaultAppID = '36544'; static const String defaultEndpoint = 'blue.derivws.com'; + // Add zoom step constant + static const double zoomStep = 1.2; // 20% zoom in/out per button press + List ticks = []; ChartStyle style = ChartStyle.line; int granularity = 0; final List _sampleBarriers = []; HorizontalBarrier? _slBarrier, _tpBarrier; - bool _sl = false, _tp = false; + bool _sl = false, _tp = false, _adaptiveInterval = true; TickHistorySubscription? _tickHistorySubscription; @@ -284,9 +287,9 @@ class _FullscreenChartState extends State { _updateSampleSLAndTP(); - WidgetsBinding.instance.addPostFrameCallback( - (Duration timeStamp) => _controller.scrollToLastTick(), - ); + // WidgetsBinding.instance.addPostFrameCallback( + // (Duration timeStamp) => _controller.scrollToLastTick(), + // ); } on BaseAPIException catch (e) { dev.log(e.message!, error: e); } finally { @@ -371,6 +374,17 @@ class _FullscreenChartState extends State { Expanded(child: _buildMarketSelectorButton()), _buildChartTypeButton(), _buildIntervalSelector(), + // Add zoom buttons + IconButton( + icon: const Icon(Icons.zoom_in, color: Colors.white), + onPressed: _zoomIn, + tooltip: 'Zoom In', + ), + IconButton( + icon: const Icon(Icons.zoom_out, color: Colors.white), + onPressed: _zoomOut, + tooltip: 'Zoom Out', + ), ], ), ), @@ -433,6 +447,38 @@ class _FullscreenChartState extends State { _loadHistory(500); } }, + chartAxisConfig: ChartAxisConfig( + autoIntervalEnabled: _adaptiveInterval, + autoIntervalTransitionDuration: + Duration(milliseconds: 480), + ), + onGranularityChangeRequested: (int suggestedGranularity) { + print( + 'Auto-interval suggests granularity: ${suggestedGranularity}ms'); + + // Only proceed if adaptive interval is enabled + if (!_adaptiveInterval) { + print( + 'Adaptive interval is disabled, ignoring suggestion'); + return; + } + + // Convert ms to seconds for the API call + final int suggestedGranularitySeconds = + suggestedGranularity ~/ 1000; + + print( + 'Converting to seconds: ${suggestedGranularitySeconds}s'); + + // Update the granularity if it's different + if (suggestedGranularitySeconds != granularity) { + print( + 'Updating granularity from ${granularity}s to ${suggestedGranularitySeconds}s'); + + // Update the granularity and fetch new data + _onIntervalSelected(suggestedGranularitySeconds); + } + }, ), ), // ignore: unnecessary_null_comparison @@ -562,20 +608,15 @@ class _FullscreenChartState extends State { height: 64, child: Row( children: [ - Expanded( - child: CheckboxListTile( - value: _sl, - onChanged: (bool? sl) => setState(() => _sl = sl!), - title: const Text('Stop loss'), - ), - ), - Expanded( - child: CheckboxListTile( - value: _tp, - onChanged: (bool? tp) => setState(() => _tp = tp!), - title: const Text('Take profit'), - ), - ), + _buildToggle( + 'Stop loss', _sl, () => setState(() => _sl = !_sl)), + _buildToggle( + 'Take profit', _tp, () => setState(() => _tp = !_tp)), + _buildToggle( + 'Adaptive interval', + _adaptiveInterval, + () => setState( + () => _adaptiveInterval = !_adaptiveInterval)), ], ), ) @@ -623,6 +664,7 @@ class _FullscreenChartState extends State { _sampleBarriers.clear(); _sl = false; _tp = false; + _adaptiveInterval = true; } Widget _buildConnectionStatus() => ConnectionStatusLabel( @@ -754,7 +796,7 @@ class _FullscreenChartState extends State { _requestCompleter = Completer(); setState(() { - ticks.clear(); + // ticks.clear(); _clearMarkers(); _clearBarriers(); }); @@ -843,4 +885,47 @@ class _FullscreenChartState extends State { authEndpoint: '', ); } + + Widget _buildToggle(String label, bool value, VoidCallback onToggle) { + return Expanded( + child: InkWell( + onTap: onToggle, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Checkbox( + value: value, + onChanged: (_) => onToggle(), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + Expanded( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + label, + style: const TextStyle(fontSize: 12), + ), + ), + ), + ], + ), + ), + ); + } + + // Add zoom methods + void _zoomIn() { + final double? currentScale = _controller.scale(zoomStep); + if (currentScale != null) { + debugPrint('Zoomed in, new msPerPx: $currentScale'); + } + } + + void _zoomOut() { + final double? currentScale = _controller.scale(1 / zoomStep); + if (currentScale != null) { + debugPrint('Zoomed out, new msPerPx: $currentScale'); + } + } } diff --git a/lib/deriv_chart.dart b/lib/deriv_chart.dart index ec5d8cfd2..4b7238528 100644 --- a/lib/deriv_chart.dart +++ b/lib/deriv_chart.dart @@ -43,6 +43,7 @@ export 'src/add_ons/indicators_ui/zigzag_indicator/zigzag_indicator_config.dart' export 'src/add_ons/repository.dart'; export 'src/deriv_chart/interactive_layer/interactive_layer_export.dart'; export 'src/deriv_chart/chart/chart.dart'; +export 'src/deriv_chart/chart/auto_interval/auto_interval_wrapper.dart'; export 'src/deriv_chart/chart/data_visualization/annotations/barriers/accumulators_barriers/accumulators_active_contract.dart'; export 'src/deriv_chart/chart/data_visualization/annotations/barriers/accumulators_barriers/accumulators_closed_indicator.dart'; export 'src/deriv_chart/chart/data_visualization/annotations/barriers/accumulators_barriers/accumulators_entry_spot_barrier.dart'; diff --git a/lib/src/deriv_chart/chart/auto_interval/auto_interval_wrapper.dart b/lib/src/deriv_chart/chart/auto_interval/auto_interval_wrapper.dart new file mode 100644 index 000000000..3b7e01356 --- /dev/null +++ b/lib/src/deriv_chart/chart/auto_interval/auto_interval_wrapper.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'zoom_level_observer.dart'; +import '../../../models/chart_axis_config.dart'; + +/// Auto-interval wrapper widget that manages automatic granularity switching. +/// +/// This widget wraps chart components and automatically manages granularity +/// changes based on zoom levels. It provides a clean interface similar to +/// GestureManager and XAxisWrapper, eliminating the need for manual coordinator +/// setup and observer pattern implementation. +/// +/// Usage: +/// ```dart +/// AutoIntervalWrapper( +/// enabled: true, +/// granularity: currentGranularity, +/// zoomRanges: autoIntervalRanges, +/// onGranularityChangeRequested: (newGranularity) { +/// // Handle granularity change request +/// }, +/// child: Chart(...), +/// ) +/// ``` +class AutoIntervalWrapper extends StatefulWidget { + /// Creates an auto-interval wrapper. + const AutoIntervalWrapper({ + required this.child, + required this.granularity, + this.enabled = false, + this.zoomRanges = defaultAutoIntervalRanges, + this.onGranularityChangeRequested, + Key? key, + }) : super(key: key); + + /// The widget below this widget in the tree. + final Widget child; + + /// Current granularity in milliseconds. + final int granularity; + + /// Whether auto-interval is enabled. + final bool enabled; + + /// Zoom range configurations for auto-interval. + final List zoomRanges; + + /// Called when a granularity change is suggested. + final void Function(int suggestedGranularity)? onGranularityChangeRequested; + + @override + AutoIntervalWrapperState createState() => AutoIntervalWrapperState(); +} + +/// State for AutoIntervalWrapper that implements ZoomLevelObserver. +class AutoIntervalWrapperState extends State + implements ZoomLevelObserver { + int _currentGranularity = 0; + int? _lastSuggestedGranularity; + + @override + void initState() { + super.initState(); + _currentGranularity = widget.granularity; + } + + @override + void didUpdateWidget(AutoIntervalWrapper oldWidget) { + super.didUpdateWidget(oldWidget); + + // Update granularity if it changed externally + if (oldWidget.granularity != widget.granularity) { + _currentGranularity = widget.granularity; + _lastSuggestedGranularity = null; // Reset suggestion tracking + } + } + + @override + void onZoomLevelChanged(double msPerPx, int currentGranularity) { + if (!widget.enabled) { + return; + } + + // Update current granularity + _currentGranularity = currentGranularity; + + // Calculate optimal granularity for current zoom level + final int? optimalGranularity = _calculateOptimalGranularity(msPerPx); + + // Suggest change if different from current and not already suggested + if (optimalGranularity != null && + optimalGranularity != _currentGranularity && + optimalGranularity != _lastSuggestedGranularity) { + _lastSuggestedGranularity = optimalGranularity; + widget.onGranularityChangeRequested?.call(optimalGranularity); + } + } + + /// Calculates the optimal granularity for the given zoom level + int? _calculateOptimalGranularity(double msPerPx) { + AutoIntervalZoomRange? bestRange; + double bestScore = double.infinity; + + for (final AutoIntervalZoomRange range in widget.zoomRanges) { + final double pixelsPerInterval = range.granularity / msPerPx; + + // Check if current zoom level fits within this range's bounds + if (pixelsPerInterval >= range.minPixelsPerInterval && + pixelsPerInterval <= range.maxPixelsPerInterval) { + // Calculate score based on distance from optimal + final double score = + (pixelsPerInterval - range.optimalPixelsPerInterval).abs(); + + if (score < bestScore) { + bestScore = score; + bestRange = range; + } + } + } + + return bestRange?.granularity; + } + + @override + Widget build(BuildContext context) { + return Provider.value( + value: this, + child: widget.child, + ); + } +} diff --git a/lib/src/deriv_chart/chart/auto_interval/zoom_level_observer.dart b/lib/src/deriv_chart/chart/auto_interval/zoom_level_observer.dart new file mode 100644 index 000000000..004c2bc03 --- /dev/null +++ b/lib/src/deriv_chart/chart/auto_interval/zoom_level_observer.dart @@ -0,0 +1,5 @@ +/// Interface for components that need to observe zoom level changes +abstract class ZoomLevelObserver { + /// Called when zoom level changes + void onZoomLevelChanged(double msPerPx, int currentGranularity); +} diff --git a/lib/src/deriv_chart/chart/basic_chart.dart b/lib/src/deriv_chart/chart/basic_chart.dart index 1faa2a627..d6f5f8db3 100644 --- a/lib/src/deriv_chart/chart/basic_chart.dart +++ b/lib/src/deriv_chart/chart/basic_chart.dart @@ -451,21 +451,31 @@ class BasicChartState extends State builder: (BuildContext context, _) => RepaintBoundary( child: Opacity( opacity: widget.opacity, - child: CustomPaint( - painter: ChartDataPainter( - animationInfo: AnimationInfo( - currentTickPercent: currentTickAnimation.value, + child: AnimatedSwitcher( + duration: context + .watch() + .chartAxisConfig + .autoIntervalTransitionDuration, + switchInCurve: const Cubic(0.72, 0, 0.24, 1), + switchOutCurve: const Cubic(0.72, 0, 0.24, 1), + child: CustomPaint( + key: ValueKey(context.watch().granularity), + size: canvasSize!, + painter: ChartDataPainter( + animationInfo: AnimationInfo( + currentTickPercent: currentTickAnimation.value, + ), + mainSeries: widget.mainSeries, + chartConfig: context.watch(), + theme: context.watch(), + epochToCanvasX: xAxis.xFromEpoch, + quoteToCanvasY: chartQuoteToCanvasY, + rightBoundEpoch: xAxis.rightBoundEpoch, + leftBoundEpoch: xAxis.leftBoundEpoch, + topY: chartQuoteToCanvasY(widget.mainSeries.maxValue), + bottomY: chartQuoteToCanvasY(widget.mainSeries.minValue), + chartScaleModel: context.watch(), ), - mainSeries: widget.mainSeries, - chartConfig: context.watch(), - theme: context.watch(), - epochToCanvasX: xAxis.xFromEpoch, - quoteToCanvasY: chartQuoteToCanvasY, - rightBoundEpoch: xAxis.rightBoundEpoch, - leftBoundEpoch: xAxis.leftBoundEpoch, - topY: chartQuoteToCanvasY(widget.mainSeries.maxValue), - bottomY: chartQuoteToCanvasY(widget.mainSeries.minValue), - chartScaleModel: context.watch(), ), ), ), diff --git a/lib/src/deriv_chart/chart/chart.dart b/lib/src/deriv_chart/chart/chart.dart index 2c25811dc..f7a997cd0 100644 --- a/lib/src/deriv_chart/chart/chart.dart +++ b/lib/src/deriv_chart/chart/chart.dart @@ -31,6 +31,7 @@ import 'data_visualization/chart_series/series.dart'; import 'data_visualization/markers/marker_series.dart'; import 'data_visualization/models/chart_object.dart'; import 'main_chart.dart'; +import 'auto_interval/auto_interval_wrapper.dart'; part 'chart_state_web.dart'; @@ -38,6 +39,10 @@ part 'chart_state_mobile.dart'; const Duration _defaultDuration = Duration(milliseconds: 300); +// Add callback type +typedef OnGranularityChangeRequestedCallback = void Function( + int suggestedGranularity); + /// Interactive chart widget. class Chart extends StatefulWidget { /// Creates chart that expands to available space. @@ -77,6 +82,7 @@ class Chart extends StatefulWidget { this.showScrollToLastTickButton, this.loadingAnimationColor, this.useDrawingToolsV2 = false, + this.onGranularityChangeRequested, Key? key, }) : super(key: key); @@ -194,6 +200,9 @@ class Chart extends StatefulWidget { /// The interactive layer behaviour. final InteractiveLayerBehaviour? interactiveLayerBehaviour; + /// Called when the chart suggests a granularity change due to zoom level. + final OnGranularityChangeRequestedCallback? onGranularityChangeRequested; + @override State createState() => // TODO(Ramin): Make this customizable from outside. @@ -281,6 +290,8 @@ abstract class _ChartState extends State with WidgetsBindingObserver { granularity: widget.granularity, msPerPx: widget.msPerPx ?? defaultMsPerPx); + // print(_chartScaleModel.toString()); + final List? overlaySeries = _getIndicatorSeries(widget.overlayConfigs); @@ -307,7 +318,7 @@ abstract class _ChartState extends State with WidgetsBindingObserver { final Duration currentTickAnimationDuration = widget.currentTickAnimationDuration ?? _defaultDuration; - return MultiProvider( + Widget chartContent = MultiProvider( providers: [ Provider.value(value: _chartTheme), Provider.value(value: chartConfig), @@ -335,6 +346,19 @@ abstract class _ChartState extends State with WidgetsBindingObserver { ), ), ); + + // Wrap with AutoIntervalWrapper if enabled + if (widget.chartAxisConfig.autoIntervalEnabled) { + chartContent = AutoIntervalWrapper( + enabled: true, + granularity: widget.granularity, + zoomRanges: widget.chartAxisConfig.autoIntervalZoomRanges, + onGranularityChangeRequested: widget.onGranularityChangeRequested, + child: chartContent, + ); + } + + return chartContent; } Widget buildChartsLayout( diff --git a/lib/src/deriv_chart/chart/x_axis/widgets/x_axis_base.dart b/lib/src/deriv_chart/chart/x_axis/widgets/x_axis_base.dart index 2e4de83fc..24d5fa3f9 100644 --- a/lib/src/deriv_chart/chart/x_axis/widgets/x_axis_base.dart +++ b/lib/src/deriv_chart/chart/x_axis/widgets/x_axis_base.dart @@ -9,6 +9,7 @@ import 'package:provider/provider.dart'; import '../grid/x_grid_painter.dart'; import '../x_axis_model.dart'; +import '../../auto_interval/zoom_level_observer.dart'; /// X-axis base widget. /// @@ -115,6 +116,11 @@ class XAxisState extends State with TickerProviderStateMixin { dataFitPadding: widget.dataFitPadding, ); + // Inject auto-interval coordinator if available + final ZoomLevelObserver? zoomLevelObserver = + context.read(); + _model.setZoomLevelObserver(zoomLevelObserver); + gestureManager = context.read() ..registerCallback(_model.onScaleAndPanStart) ..registerCallback(_model.onScaleUpdate) diff --git a/lib/src/deriv_chart/chart/x_axis/x_axis_model.dart b/lib/src/deriv_chart/chart/x_axis/x_axis_model.dart index b5614cd63..3d0e7829b 100644 --- a/lib/src/deriv_chart/chart/x_axis/x_axis_model.dart +++ b/lib/src/deriv_chart/chart/x_axis/x_axis_model.dart @@ -12,6 +12,7 @@ import 'functions/calc_no_overlay_time_gaps.dart'; import 'gaps/gap_manager.dart'; import 'gaps/helpers.dart'; import 'grid/calc_time_grid.dart'; +import '../auto_interval/zoom_level_observer.dart'; /// Will stop auto-panning when the last tick has reached to this offset from /// the [XAxisModel.leftBoundEpoch]. @@ -329,8 +330,8 @@ class XAxisModel extends ChangeNotifier { return; } _granularity = newGranularity; - _msPerPx = _defaultMsPerPx; - _scrollTo(_maxRightBoundEpoch); + // _msPerPx = _defaultMsPerPx; + // _scrollTo(_maxRightBoundEpoch); } /// Updates chart's isLive property. @@ -470,12 +471,26 @@ class XAxisModel extends ChangeNotifier { if (!_isScrollBlocked) { _triggerScrollMomentum(details.velocity); } + + _zoomLevelObserver?.onZoomLevelChanged(_msPerPx, _granularity); + } + + /// Optional zoom level observer for auto-interval feature + ZoomLevelObserver? _zoomLevelObserver; + + /// Sets the zoom level observer (for auto-interval feature) + void setZoomLevelObserver(ZoomLevelObserver? observer) { + _zoomLevelObserver = observer; } - /// Called to scale the chart + /// Enhanced scale method void scale(double scale) { _msPerPx = (_prevMsPerPx! / scale).clamp(_minMsPerPx, _maxMsPerPx); + print('_msPerPx: $_msPerPx'); onScale?.call(); + if (kIsWeb) { + _zoomLevelObserver?.onZoomLevelChanged(_msPerPx, _granularity); + } notifyListeners(); } diff --git a/lib/src/deriv_chart/deriv_chart.dart b/lib/src/deriv_chart/deriv_chart.dart index 01e999a8a..3f2d8fc57 100644 --- a/lib/src/deriv_chart/deriv_chart.dart +++ b/lib/src/deriv_chart/deriv_chart.dart @@ -42,6 +42,7 @@ class DerivChart extends StatefulWidget { this.onCrosshairHover, this.onVisibleAreaChanged, this.onQuoteAreaChanged, + this.onGranularityChangeRequested, this.theme, this.isLive = false, this.dataFitEnabled = false, @@ -107,6 +108,11 @@ class DerivChart extends StatefulWidget { /// Callback provided by library user. final VisibleQuoteAreaChangedCallback? onQuoteAreaChanged; + /// Called when the chart suggests a granularity change due to zoom level. + /// This is used by the auto-interval feature to notify consumers + /// that they should request new data with a different granularity. + final void Function(int suggestedGranularity)? onGranularityChangeRequested; + /// Chart's theme. final ChartTheme? theme; @@ -362,6 +368,8 @@ class _DerivChartState extends State { onCrosshairHover: widget.onCrosshairHover, onVisibleAreaChanged: widget.onVisibleAreaChanged, onQuoteAreaChanged: widget.onQuoteAreaChanged, + onGranularityChangeRequested: + widget.onGranularityChangeRequested, isLive: widget.isLive, dataFitEnabled: widget.dataFitEnabled, opacity: widget.opacity, diff --git a/lib/src/models/chart_axis_config.dart b/lib/src/models/chart_axis_config.dart index b717a949c..74e8baf1f 100644 --- a/lib/src/models/chart_axis_config.dart +++ b/lib/src/models/chart_axis_config.dart @@ -10,6 +10,94 @@ const double defaultBottomBoundQuote = 30; /// Limits panning to the right. const double defaultMaxCurrentTickOffset = 150; +/// Configuration for auto-interval zoom ranges. +/// Maps granularity (in milliseconds) to the optimal pixel range for that interval. +@immutable +class AutoIntervalZoomRange { + const AutoIntervalZoomRange({ + required this.granularity, + required this.minPixelsPerInterval, + required this.maxPixelsPerInterval, + this.optimalPixelsPerInterval = 40.0, + }); + + /// The granularity in milliseconds (e.g., 60000 for 1-minute candles) + final int granularity; + + /// Minimum pixels per interval before switching to smaller granularity + final double minPixelsPerInterval; + + /// Maximum pixels per interval before switching to larger granularity + final double maxPixelsPerInterval; + + /// Optimal pixels per interval for this granularity + final double optimalPixelsPerInterval; +} + +/// Default auto-interval configuration for trading timeframes +const List defaultAutoIntervalRanges = [ + AutoIntervalZoomRange( + granularity: 60000, + minPixelsPerInterval: 10, + maxPixelsPerInterval: 1000, + ), // 1 minute - _msPerPx range: ( - 6000) + AutoIntervalZoomRange( + granularity: 120000, + minPixelsPerInterval: 10, + maxPixelsPerInterval: 40, + ), // 2 minutes - _msPerPx range: (3000 - 12000) + AutoIntervalZoomRange( + granularity: 180000, + minPixelsPerInterval: 10, + maxPixelsPerInterval: 30, + ), // 3 minutes - _msPerPx range: (6000 - 18000) + AutoIntervalZoomRange( + granularity: 300000, + minPixelsPerInterval: 10, + maxPixelsPerInterval: 25, + ), // 5 minutes - _msPerPx range: (12000 - 30000) + AutoIntervalZoomRange( + granularity: 600000, + minPixelsPerInterval: 10, + maxPixelsPerInterval: 33, + ), // 10 minutes - _msPerPx range: (18000 - 60000) + AutoIntervalZoomRange( + granularity: 900000, + minPixelsPerInterval: 10, + maxPixelsPerInterval: 30, + ), // 15 minutes - _msPerPx range: (30000 - 90000) + AutoIntervalZoomRange( + granularity: 1800000, + minPixelsPerInterval: 10, + maxPixelsPerInterval: 30, + ), // 30 minutes - _msPerPx range: (60000 - 180000) + AutoIntervalZoomRange( + granularity: 3600000, + minPixelsPerInterval: 10, + maxPixelsPerInterval: 40, + ), // 1 hour - _msPerPx range: (90000 - 360000) + AutoIntervalZoomRange( + granularity: 7200000, + minPixelsPerInterval: 10, + maxPixelsPerInterval: 40, + ), // 2 hours - _msPerPx range: (180000 - 720000) + AutoIntervalZoomRange( + granularity: 14400000, + minPixelsPerInterval: 10, + maxPixelsPerInterval: 40, + ), // 4 hours - _msPerPx range: (360000 - 1440000) + AutoIntervalZoomRange( + granularity: 28800000, + minPixelsPerInterval: 10, + maxPixelsPerInterval: 40, + ), // 8 hours - _msPerPx range: (720000 - 2880000) + AutoIntervalZoomRange( + granularity: 86400000, + minPixelsPerInterval: 1, + maxPixelsPerInterval: 30, + ), // 1 day - _msPerPx range: (2880000 - ) +]; + /// Configuration for the chart axis. @immutable class ChartAxisConfig { @@ -23,6 +111,9 @@ class ChartAxisConfig { this.showEpochGrid = true, this.showFrame = false, this.smoothScrolling = true, + this.autoIntervalEnabled = false, + this.autoIntervalZoomRanges = defaultAutoIntervalRanges, + this.autoIntervalTransitionDuration = const Duration(milliseconds: 480), }); /// Top quote bound target for animated transition. @@ -60,16 +151,36 @@ class ChartAxisConfig { /// Default is `true`. final bool smoothScrolling; + /// Whether auto-interval adjustment is enabled. + /// When enabled, the chart will automatically suggest granularity changes + /// based on zoom level to maintain optimal readability. + final bool autoIntervalEnabled; + + /// Configuration for auto-interval zoom ranges. + /// Each range defines the optimal pixel range for a specific granularity. + final List autoIntervalZoomRanges; + + /// Duration of the granularity transition animation. + final Duration autoIntervalTransitionDuration; + /// Creates a copy of this ChartAxisConfig but with the given fields replaced. ChartAxisConfig copyWith({ double? initialTopBoundQuote, double? initialBottomBoundQuote, double? maxCurrentTickOffset, + bool? autoIntervalEnabled, + List? autoIntervalZoomRanges, + Duration? autoIntervalTransitionDuration, }) => ChartAxisConfig( initialTopBoundQuote: initialTopBoundQuote ?? this.initialTopBoundQuote, initialBottomBoundQuote: initialBottomBoundQuote ?? this.initialBottomBoundQuote, maxCurrentTickOffset: maxCurrentTickOffset ?? this.maxCurrentTickOffset, + autoIntervalEnabled: autoIntervalEnabled ?? this.autoIntervalEnabled, + autoIntervalZoomRanges: + autoIntervalZoomRanges ?? this.autoIntervalZoomRanges, + autoIntervalTransitionDuration: autoIntervalTransitionDuration ?? + this.autoIntervalTransitionDuration, ); } From 0925836a27acc9419fa9c125c5f987c87bb6c782 Mon Sep 17 00:00:00 2001 From: behnam-deriv <133759298+behnam-deriv@users.noreply.github.com> Date: Tue, 17 Jun 2025 18:28:41 +0800 Subject: [PATCH 2/7] chore: extract AnimatedSwitcher as a widget --- lib/src/deriv_chart/chart/basic_chart.dart | 9 ++-- lib/src/widgets/maybe_animated_switcher.dart | 48 ++++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 lib/src/widgets/maybe_animated_switcher.dart diff --git a/lib/src/deriv_chart/chart/basic_chart.dart b/lib/src/deriv_chart/chart/basic_chart.dart index d6f5f8db3..9063affa1 100644 --- a/lib/src/deriv_chart/chart/basic_chart.dart +++ b/lib/src/deriv_chart/chart/basic_chart.dart @@ -20,6 +20,7 @@ import 'helpers/functions/conversion.dart'; import 'helpers/functions/helper_functions.dart'; import 'multiple_animated_builder.dart'; import 'y_axis/quote_grid.dart'; +import 'package:deriv_chart/src/widgets/maybe_animated_switcher.dart'; const Duration _defaultDuration = Duration(milliseconds: 300); @@ -451,13 +452,15 @@ class BasicChartState extends State builder: (BuildContext context, _) => RepaintBoundary( child: Opacity( opacity: widget.opacity, - child: AnimatedSwitcher( + child: MaybeAnimatedSwitcher( + enabled: context + .watch() + .chartAxisConfig + .autoIntervalEnabled, duration: context .watch() .chartAxisConfig .autoIntervalTransitionDuration, - switchInCurve: const Cubic(0.72, 0, 0.24, 1), - switchOutCurve: const Cubic(0.72, 0, 0.24, 1), child: CustomPaint( key: ValueKey(context.watch().granularity), size: canvasSize!, diff --git a/lib/src/widgets/maybe_animated_switcher.dart b/lib/src/widgets/maybe_animated_switcher.dart new file mode 100644 index 000000000..cc22371db --- /dev/null +++ b/lib/src/widgets/maybe_animated_switcher.dart @@ -0,0 +1,48 @@ +import 'package:deriv_chart/src/theme/design_tokens/core_design_tokens.dart'; +import 'package:flutter/material.dart'; + +/// {@template maybe_animated_switcher} +/// A widget that conditionally wraps its child in an [AnimatedSwitcher] +/// if [enabled] is true. Otherwise, it just returns the child. +/// {@endtemplate} +class MaybeAnimatedSwitcher extends StatelessWidget { + /// {@macro maybe_animated_switcher} + const MaybeAnimatedSwitcher({ + required this.enabled, + required this.duration, + required this.child, + Key? key, + this.switchInCurve = CoreDesignTokens.coreMotionEase400, + this.switchOutCurve = CoreDesignTokens.coreMotionEase400, + }) : super(key: key); + + /// Whether to animate the child. + /// + /// If [enabled] is false, the child will not be animated. + final bool enabled; + + /// The duration of the animation. + final Duration duration; + + /// The child to wrap in an [AnimatedSwitcher]. + final Widget child; + + /// The curve to use for the animation when the child is being switched in. + final Curve switchInCurve; + + /// The curve to use for the animation when the child is being switched out. + final Curve switchOutCurve; + + @override + Widget build(BuildContext context) { + if (enabled) { + return AnimatedSwitcher( + duration: duration, + switchInCurve: switchInCurve, + switchOutCurve: switchOutCurve, + child: child, + ); + } + return child; + } +} From 0ea09925afb1b9e650cb743b7ee9a2a616ead25b Mon Sep 17 00:00:00 2001 From: behnam-deriv <133759298+behnam-deriv@users.noreply.github.com> Date: Tue, 17 Jun 2025 18:31:45 +0800 Subject: [PATCH 3/7] fix: keep chart state when auto interval toggles on and off --- .../auto_interval/auto_interval_wrapper.dart | 52 ++++++++++---- lib/src/deriv_chart/chart/chart.dart | 69 ++++++++----------- .../chart/x_axis/widgets/x_axis_base.dart | 5 +- .../chart/x_axis/x_axis_model.dart | 56 ++++++++------- 4 files changed, 104 insertions(+), 78 deletions(-) diff --git a/lib/src/deriv_chart/chart/auto_interval/auto_interval_wrapper.dart b/lib/src/deriv_chart/chart/auto_interval/auto_interval_wrapper.dart index 3b7e01356..411098463 100644 --- a/lib/src/deriv_chart/chart/auto_interval/auto_interval_wrapper.dart +++ b/lib/src/deriv_chart/chart/auto_interval/auto_interval_wrapper.dart @@ -55,7 +55,8 @@ class AutoIntervalWrapper extends StatefulWidget { /// State for AutoIntervalWrapper that implements ZoomLevelObserver. class AutoIntervalWrapperState extends State implements ZoomLevelObserver { - int _currentGranularity = 0; + late int _currentGranularity; + double? _currentMsPerPx; int? _lastSuggestedGranularity; @override @@ -73,26 +74,23 @@ class AutoIntervalWrapperState extends State _currentGranularity = widget.granularity; _lastSuggestedGranularity = null; // Reset suggestion tracking } + + // Suggest granularity change if auto-interval is re-enabled. + if (!oldWidget.enabled && widget.enabled) { + _requestGranularityChangeIfNeeded(); + } } @override void onZoomLevelChanged(double msPerPx, int currentGranularity) { - if (!widget.enabled) { - return; - } - - // Update current granularity + // Update current granularity and msPerPx _currentGranularity = currentGranularity; + _currentMsPerPx = msPerPx; - // Calculate optimal granularity for current zoom level - final int? optimalGranularity = _calculateOptimalGranularity(msPerPx); - - // Suggest change if different from current and not already suggested - if (optimalGranularity != null && - optimalGranularity != _currentGranularity && - optimalGranularity != _lastSuggestedGranularity) { - _lastSuggestedGranularity = optimalGranularity; - widget.onGranularityChangeRequested?.call(optimalGranularity); + // Calculate optimal granularity for current zoom level if auto-interval + // is enabled. + if (widget.enabled) { + _requestGranularityChangeIfNeeded(); } } @@ -102,6 +100,9 @@ class AutoIntervalWrapperState extends State double bestScore = double.infinity; for (final AutoIntervalZoomRange range in widget.zoomRanges) { + + // The number of pixels that one interval (candle) will occupy on the + // chart at current zoom level. final double pixelsPerInterval = range.granularity / msPerPx; // Check if current zoom level fits within this range's bounds @@ -121,6 +122,27 @@ class AutoIntervalWrapperState extends State return bestRange?.granularity; } + /// Requests a granularity change if the optimal granularity is different + /// from the current and not already requested. + void _requestGranularityChangeIfNeeded() { + if (_currentMsPerPx == null) { + return; + } + + final int? optimalGranularity = + _calculateOptimalGranularity(_currentMsPerPx!); + + if (optimalGranularity != null && + optimalGranularity != _currentGranularity && + optimalGranularity != _lastSuggestedGranularity) { + _lastSuggestedGranularity = optimalGranularity; + // Defer the callback to the next frame + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onGranularityChangeRequested?.call(optimalGranularity); + }); + } + } + @override Widget build(BuildContext context) { return Provider.value( diff --git a/lib/src/deriv_chart/chart/chart.dart b/lib/src/deriv_chart/chart/chart.dart index f7a997cd0..61a50cf9b 100644 --- a/lib/src/deriv_chart/chart/chart.dart +++ b/lib/src/deriv_chart/chart/chart.dart @@ -290,8 +290,6 @@ abstract class _ChartState extends State with WidgetsBindingObserver { granularity: widget.granularity, msPerPx: widget.msPerPx ?? defaultMsPerPx); - // print(_chartScaleModel.toString()); - final List? overlaySeries = _getIndicatorSeries(widget.overlayConfigs); @@ -318,47 +316,40 @@ abstract class _ChartState extends State with WidgetsBindingObserver { final Duration currentTickAnimationDuration = widget.currentTickAnimationDuration ?? _defaultDuration; - Widget chartContent = MultiProvider( - providers: [ - Provider.value(value: _chartTheme), - Provider.value(value: chartConfig), - Provider.value(value: _chartScaleModel), - ], - child: Ink( - color: _chartTheme.backgroundColor, - child: GestureManager( - child: XAxisWrapper( - maxEpoch: chartDataList.getMaxEpoch(), - minEpoch: chartDataList.getMinEpoch(), - chartAxisConfig: widget.chartAxisConfig, - entries: widget.mainSeries.input, - pipSize: widget.pipSize, - onVisibleAreaChanged: _onVisibleAreaChanged, - isLive: widget.isLive, - startWithDataFitMode: widget.dataFitEnabled, - msPerPx: widget.msPerPx, - minIntervalWidth: widget.minIntervalWidth, - maxIntervalWidth: widget.maxIntervalWidth, - dataFitPadding: widget.dataFitPadding, - scrollAnimationDuration: currentTickAnimationDuration, - child: buildChartsLayout(context, overlaySeries, bottomSeries), + return AutoIntervalWrapper( + enabled: widget.chartAxisConfig.autoIntervalEnabled, + granularity: widget.granularity, + zoomRanges: widget.chartAxisConfig.autoIntervalZoomRanges, + onGranularityChangeRequested: widget.onGranularityChangeRequested, + child: MultiProvider( + providers: [ + Provider.value(value: _chartTheme), + Provider.value(value: chartConfig), + Provider.value(value: _chartScaleModel), + ], + child: Ink( + color: _chartTheme.backgroundColor, + child: GestureManager( + child: XAxisWrapper( + maxEpoch: chartDataList.getMaxEpoch(), + minEpoch: chartDataList.getMinEpoch(), + chartAxisConfig: widget.chartAxisConfig, + entries: widget.mainSeries.input, + pipSize: widget.pipSize, + onVisibleAreaChanged: _onVisibleAreaChanged, + isLive: widget.isLive, + startWithDataFitMode: widget.dataFitEnabled, + msPerPx: widget.msPerPx, + minIntervalWidth: widget.minIntervalWidth, + maxIntervalWidth: widget.maxIntervalWidth, + dataFitPadding: widget.dataFitPadding, + scrollAnimationDuration: currentTickAnimationDuration, + child: buildChartsLayout(context, overlaySeries, bottomSeries), + ), ), ), ), ); - - // Wrap with AutoIntervalWrapper if enabled - if (widget.chartAxisConfig.autoIntervalEnabled) { - chartContent = AutoIntervalWrapper( - enabled: true, - granularity: widget.granularity, - zoomRanges: widget.chartAxisConfig.autoIntervalZoomRanges, - onGranularityChangeRequested: widget.onGranularityChangeRequested, - child: chartContent, - ); - } - - return chartContent; } Widget buildChartsLayout( diff --git a/lib/src/deriv_chart/chart/x_axis/widgets/x_axis_base.dart b/lib/src/deriv_chart/chart/x_axis/widgets/x_axis_base.dart index 24d5fa3f9..ce8633bc1 100644 --- a/lib/src/deriv_chart/chart/x_axis/widgets/x_axis_base.dart +++ b/lib/src/deriv_chart/chart/x_axis/widgets/x_axis_base.dart @@ -114,12 +114,13 @@ class XAxisState extends State with TickerProviderStateMixin { minIntervalWidth: widget.minIntervalWidth, maxIntervalWidth: widget.maxIntervalWidth, dataFitPadding: widget.dataFitPadding, + autoIntervalEnabled: chartConfig.chartAxisConfig.autoIntervalEnabled, ); // Inject auto-interval coordinator if available final ZoomLevelObserver? zoomLevelObserver = context.read(); - _model.setZoomLevelObserver(zoomLevelObserver); + _model.zoomLevelObserver = zoomLevelObserver; gestureManager = context.read() ..registerCallback(_model.onScaleAndPanStart) @@ -146,6 +147,8 @@ class XAxisState extends State with TickerProviderStateMixin { dataFitPadding: widget.dataFitPadding, maxCurrentTickOffset: context.read().chartAxisConfig.maxCurrentTickOffset, + autoIntervalEnabled: + context.read().chartAxisConfig.autoIntervalEnabled, ); } diff --git a/lib/src/deriv_chart/chart/x_axis/x_axis_model.dart b/lib/src/deriv_chart/chart/x_axis/x_axis_model.dart index 3d0e7829b..373b1e805 100644 --- a/lib/src/deriv_chart/chart/x_axis/x_axis_model.dart +++ b/lib/src/deriv_chart/chart/x_axis/x_axis_model.dart @@ -50,23 +50,23 @@ enum ViewingMode { /// State and methods of chart's x-axis. class XAxisModel extends ChangeNotifier { /// Creates x-axis model for live chart. - XAxisModel({ - required List entries, - required int granularity, - required AnimationController animationController, - required bool isLive, - required double maxCurrentTickOffset, - this.defaultIntervalWidth = 20, - bool startWithDataFitMode = false, - int? minEpoch, - int? maxEpoch, - double? msPerPx, - double? minIntervalWidth, - double? maxIntervalWidth, - EdgeInsets? dataFitPadding, - this.onScale, - this.onScroll, - }) { + XAxisModel( + {required List entries, + required int granularity, + required AnimationController animationController, + required bool isLive, + required double maxCurrentTickOffset, + this.defaultIntervalWidth = 20, + bool startWithDataFitMode = false, + int? minEpoch, + int? maxEpoch, + double? msPerPx, + double? minIntervalWidth, + double? maxIntervalWidth, + EdgeInsets? dataFitPadding, + this.onScale, + this.onScroll, + bool autoIntervalEnabled = false}) { _maxCurrentTickOffset = maxCurrentTickOffset; _nowEpoch = entries.isNotEmpty @@ -90,6 +90,8 @@ class XAxisModel extends ChangeNotifier { _dataFitPadding = dataFitPadding ?? defaultDataFitPadding; + _autoIntervalEnabled = autoIntervalEnabled; + _updateEntries(entries); _scrollAnimationController = animationController @@ -105,6 +107,8 @@ class XAxisModel extends ChangeNotifier { }); } + late bool _autoIntervalEnabled; + late double _minIntervalWidth; late double _maxIntervalWidth; @@ -330,8 +334,10 @@ class XAxisModel extends ChangeNotifier { return; } _granularity = newGranularity; - // _msPerPx = _defaultMsPerPx; - // _scrollTo(_maxRightBoundEpoch); + if (!_autoIntervalEnabled) { + _msPerPx = _defaultMsPerPx; + _scrollTo(_maxRightBoundEpoch); + } } /// Updates chart's isLive property. @@ -472,24 +478,26 @@ class XAxisModel extends ChangeNotifier { _triggerScrollMomentum(details.velocity); } - _zoomLevelObserver?.onZoomLevelChanged(_msPerPx, _granularity); + zoomLevelObserver?.onZoomLevelChanged(_msPerPx, _granularity); } /// Optional zoom level observer for auto-interval feature ZoomLevelObserver? _zoomLevelObserver; /// Sets the zoom level observer (for auto-interval feature) - void setZoomLevelObserver(ZoomLevelObserver? observer) { + set zoomLevelObserver(ZoomLevelObserver? observer) { _zoomLevelObserver = observer; } + /// Gets the zoom level observer (for auto-interval feature) + ZoomLevelObserver? get zoomLevelObserver => _zoomLevelObserver; + /// Enhanced scale method void scale(double scale) { _msPerPx = (_prevMsPerPx! / scale).clamp(_minMsPerPx, _maxMsPerPx); - print('_msPerPx: $_msPerPx'); onScale?.call(); if (kIsWeb) { - _zoomLevelObserver?.onZoomLevelChanged(_msPerPx, _granularity); + zoomLevelObserver?.onZoomLevelChanged(_msPerPx, _granularity); } notifyListeners(); } @@ -576,6 +584,7 @@ class XAxisModel extends ChangeNotifier { int? maxEpoch, EdgeInsets? dataFitPadding, double? maxCurrentTickOffset, + bool? autoIntervalEnabled, }) { _updateIsLive(isLive); _updateGranularity(granularity); @@ -585,6 +594,7 @@ class XAxisModel extends ChangeNotifier { _maxEpoch = maxEpoch ?? _maxEpoch; _dataFitPadding = dataFitPadding ?? _dataFitPadding; _maxCurrentTickOffset = maxCurrentTickOffset ?? _maxCurrentTickOffset; + _autoIntervalEnabled = autoIntervalEnabled ?? _autoIntervalEnabled; } /// Returns a list of timestamps in the grid without any overlaps. From ff4f0c3bef30f97ed14087cf9061792f56080181 Mon Sep 17 00:00:00 2001 From: behnam-deriv <133759298+behnam-deriv@users.noreply.github.com> Date: Tue, 17 Jun 2025 18:32:25 +0800 Subject: [PATCH 4/7] chore: adjust default auto interval ranges --- lib/src/models/chart_axis_config.dart | 80 ++++++++++++++------------- 1 file changed, 42 insertions(+), 38 deletions(-) diff --git a/lib/src/models/chart_axis_config.dart b/lib/src/models/chart_axis_config.dart index 74e8baf1f..f815b13a8 100644 --- a/lib/src/models/chart_axis_config.dart +++ b/lib/src/models/chart_axis_config.dart @@ -1,3 +1,4 @@ +import 'package:deriv_chart/src/theme/design_tokens/core_design_tokens.dart'; import 'package:flutter/foundation.dart'; /// Default top bound quote. @@ -10,15 +11,18 @@ const double defaultBottomBoundQuote = 30; /// Limits panning to the right. const double defaultMaxCurrentTickOffset = 150; +/// {@template auto_interval_zoom_range} /// Configuration for auto-interval zoom ranges. /// Maps granularity (in milliseconds) to the optimal pixel range for that interval. +/// {@endtemplate} @immutable class AutoIntervalZoomRange { + /// {@macro auto_interval_zoom_range} const AutoIntervalZoomRange({ required this.granularity, required this.minPixelsPerInterval, required this.maxPixelsPerInterval, - this.optimalPixelsPerInterval = 40.0, + this.optimalPixelsPerInterval = 18.0, }); /// The granularity in milliseconds (e.g., 60000 for 1-minute candles) @@ -38,64 +42,64 @@ class AutoIntervalZoomRange { const List defaultAutoIntervalRanges = [ AutoIntervalZoomRange( granularity: 60000, - minPixelsPerInterval: 10, - maxPixelsPerInterval: 1000, - ), // 1 minute - _msPerPx range: ( - 6000) + minPixelsPerInterval: 12, + maxPixelsPerInterval: 24, + ), // 1 minute AutoIntervalZoomRange( granularity: 120000, - minPixelsPerInterval: 10, - maxPixelsPerInterval: 40, - ), // 2 minutes - _msPerPx range: (3000 - 12000) + minPixelsPerInterval: 12, + maxPixelsPerInterval: 24, + ), // 2 minutes AutoIntervalZoomRange( granularity: 180000, - minPixelsPerInterval: 10, - maxPixelsPerInterval: 30, - ), // 3 minutes - _msPerPx range: (6000 - 18000) + minPixelsPerInterval: 12, + maxPixelsPerInterval: 24, + ), // 3 minutes AutoIntervalZoomRange( granularity: 300000, - minPixelsPerInterval: 10, - maxPixelsPerInterval: 25, - ), // 5 minutes - _msPerPx range: (12000 - 30000) + minPixelsPerInterval: 12, + maxPixelsPerInterval: 24, + ), // 5 minutes AutoIntervalZoomRange( granularity: 600000, - minPixelsPerInterval: 10, - maxPixelsPerInterval: 33, - ), // 10 minutes - _msPerPx range: (18000 - 60000) + minPixelsPerInterval: 12, + maxPixelsPerInterval: 24, + ), // 10 minutes AutoIntervalZoomRange( granularity: 900000, - minPixelsPerInterval: 10, - maxPixelsPerInterval: 30, - ), // 15 minutes - _msPerPx range: (30000 - 90000) + minPixelsPerInterval: 12, + maxPixelsPerInterval: 24, + ), // 15 minutes AutoIntervalZoomRange( granularity: 1800000, - minPixelsPerInterval: 10, - maxPixelsPerInterval: 30, - ), // 30 minutes - _msPerPx range: (60000 - 180000) + minPixelsPerInterval: 12, + maxPixelsPerInterval: 24, + ), // 30 minutes AutoIntervalZoomRange( granularity: 3600000, - minPixelsPerInterval: 10, - maxPixelsPerInterval: 40, - ), // 1 hour - _msPerPx range: (90000 - 360000) + minPixelsPerInterval: 12, + maxPixelsPerInterval: 24, + ), // 1 hour AutoIntervalZoomRange( granularity: 7200000, - minPixelsPerInterval: 10, - maxPixelsPerInterval: 40, - ), // 2 hours - _msPerPx range: (180000 - 720000) + minPixelsPerInterval: 12, + maxPixelsPerInterval: 24, + ), // 2 hours AutoIntervalZoomRange( granularity: 14400000, - minPixelsPerInterval: 10, - maxPixelsPerInterval: 40, - ), // 4 hours - _msPerPx range: (360000 - 1440000) + minPixelsPerInterval: 12, + maxPixelsPerInterval: 24, + ), // 4 hours AutoIntervalZoomRange( granularity: 28800000, - minPixelsPerInterval: 10, - maxPixelsPerInterval: 40, - ), // 8 hours - _msPerPx range: (720000 - 2880000) + minPixelsPerInterval: 12, + maxPixelsPerInterval: 24, + ), // 8 hours AutoIntervalZoomRange( granularity: 86400000, - minPixelsPerInterval: 1, - maxPixelsPerInterval: 30, - ), // 1 day - _msPerPx range: (2880000 - ) + minPixelsPerInterval: 12, + maxPixelsPerInterval: 24, + ), // 1 day ]; /// Configuration for the chart axis. @@ -113,7 +117,7 @@ class ChartAxisConfig { this.smoothScrolling = true, this.autoIntervalEnabled = false, this.autoIntervalZoomRanges = defaultAutoIntervalRanges, - this.autoIntervalTransitionDuration = const Duration(milliseconds: 480), + this.autoIntervalTransitionDuration = CoreDesignTokens.motionDurationSnappy, }); /// Top quote bound target for animated transition. From 7b607122b7cc2ce4f75893d1610525627a519ae6 Mon Sep 17 00:00:00 2001 From: behnam-deriv <133759298+behnam-deriv@users.noreply.github.com> Date: Wed, 18 Jun 2025 11:27:05 +0800 Subject: [PATCH 5/7] chore: add adaptive interval toggle and zoom in/out buttons in example app --- example/lib/main.dart | 68 ++++++++++++++++++++----------------------- 1 file changed, 31 insertions(+), 37 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index df4ac6897..649c5ef4b 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -287,9 +287,11 @@ class _FullscreenChartState extends State { _updateSampleSLAndTP(); - // WidgetsBinding.instance.addPostFrameCallback( - // (Duration timeStamp) => _controller.scrollToLastTick(), - // ); + if (!_adaptiveInterval) { + WidgetsBinding.instance.addPostFrameCallback( + (Duration timeStamp) => _controller.scrollToLastTick(), + ); + } } on BaseAPIException catch (e) { dev.log(e.message!, error: e); } finally { @@ -374,17 +376,6 @@ class _FullscreenChartState extends State { Expanded(child: _buildMarketSelectorButton()), _buildChartTypeButton(), _buildIntervalSelector(), - // Add zoom buttons - IconButton( - icon: const Icon(Icons.zoom_in, color: Colors.white), - onPressed: _zoomIn, - tooltip: 'Zoom In', - ), - IconButton( - icon: const Icon(Icons.zoom_out, color: Colors.white), - onPressed: _zoomOut, - tooltip: 'Zoom Out', - ), ], ), ), @@ -496,6 +487,20 @@ class _FullscreenChartState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ + Row( + children: [ + IconButton( + icon: const Icon(Icons.zoom_out), + onPressed: _zoomOut, + tooltip: 'Zoom Out', + ), + IconButton( + icon: const Icon(Icons.zoom_in), + onPressed: _zoomIn, + tooltip: 'Zoom In', + ), + ], + ), IconButton( icon: const Icon(Icons.settings), onPressed: () async { @@ -664,7 +669,6 @@ class _FullscreenChartState extends State { _sampleBarriers.clear(); _sl = false; _tp = false; - _adaptiveInterval = true; } Widget _buildConnectionStatus() => ConnectionStatusLabel( @@ -796,7 +800,9 @@ class _FullscreenChartState extends State { _requestCompleter = Completer(); setState(() { - // ticks.clear(); + if (!_adaptiveInterval) { + ticks.clear(); + } _clearMarkers(); _clearBarriers(); }); @@ -888,28 +894,16 @@ class _FullscreenChartState extends State { Widget _buildToggle(String label, bool value, VoidCallback onToggle) { return Expanded( - child: InkWell( - onTap: onToggle, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Checkbox( - value: value, - onChanged: (_) => onToggle(), - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - Expanded( - child: FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Text( - label, - style: const TextStyle(fontSize: 12), - ), - ), - ), - ], + child: CheckboxListTile( + title: Text( + label, + style: const TextStyle(fontSize: 14), ), + value: value, + onChanged: (_) => onToggle(), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + dense: true, ), ); } From 84f87fb286099bb45632aabbaf8962069c3c1783 Mon Sep 17 00:00:00 2001 From: behnam-deriv <133759298+behnam-deriv@users.noreply.github.com> Date: Wed, 18 Jun 2025 12:01:12 +0800 Subject: [PATCH 6/7] chore: update docs --- AUTO_INTERVAL_USAGE.md | 219 ------------------ doc/adaptive_interval.md | 120 ++++++++++ .../auto_interval/auto_interval_wrapper.dart | 5 +- 3 files changed, 122 insertions(+), 222 deletions(-) delete mode 100644 AUTO_INTERVAL_USAGE.md create mode 100644 doc/adaptive_interval.md diff --git a/AUTO_INTERVAL_USAGE.md b/AUTO_INTERVAL_USAGE.md deleted file mode 100644 index 8d5c67f74..000000000 --- a/AUTO_INTERVAL_USAGE.md +++ /dev/null @@ -1,219 +0,0 @@ -# Auto Interval Wrapper Usage - -The `AutoIntervalWrapper` provides a simplified way to add automatic granularity switching to your charts based on zoom levels. It follows the same wrapper pattern as `GestureManager` and `XAxisWrapper`. - -**✨ Fully Self-Contained**: No separate manager classes needed - all logic is built directly into the wrapper! - -## Basic Usage - -### With Chart Widget - -```dart -import 'package:deriv_chart/deriv_chart.dart'; - -// Wrap your chart with AutoIntervalWrapper -AutoIntervalWrapper( - enabled: true, - granularity: currentGranularity, // in milliseconds - zoomRanges: const [ - AutoIntervalZoomRange( - granularity: 60000, // 1 minute - minPixelsPerInterval: 20, - maxPixelsPerInterval: 120, - optimalPixelsPerInterval: 40, - ), - AutoIntervalZoomRange( - granularity: 300000, // 5 minutes - minPixelsPerInterval: 20, - maxPixelsPerInterval: 120, - optimalPixelsPerInterval: 40, - ), - // Add more ranges as needed - ], - onGranularityChangeRequested: (int suggestedGranularity) { - // Handle granularity change request - print('Auto-interval suggests: ${suggestedGranularity}ms'); - - // Update your data and granularity - updateChartGranularity(suggestedGranularity ~/ 1000); // Convert to seconds - }, - child: Chart( - mainSeries: yourDataSeries, - granularity: currentGranularity, - // ... other chart properties - ), -) -``` - -### With DerivChart Widget - -```dart -AutoIntervalWrapper( - enabled: true, - granularity: currentGranularity, - zoomRanges: defaultAutoIntervalRanges, // Use predefined ranges - onGranularityChangeRequested: (int suggestedGranularity) { - // Handle the suggestion - final int suggestedGranularitySeconds = suggestedGranularity ~/ 1000; - - if (suggestedGranularitySeconds != currentGranularitySeconds) { - // Update granularity and fetch new data - fetchNewData(suggestedGranularitySeconds); - } - }, - child: DerivChart( - mainSeries: yourDataSeries, - granularity: currentGranularity, - activeSymbol: currentSymbol, - // ... other chart properties - ), -) -``` - -## Configuration - -### AutoIntervalZoomRange Parameters - -- `granularity`: The time interval in milliseconds (e.g., 60000 for 1-minute candles) -- `minPixelsPerInterval`: Minimum pixels per interval before switching to smaller granularity -- `maxPixelsPerInterval`: Maximum pixels per interval before switching to larger granularity -- `optimalPixelsPerInterval`: Optimal pixels per interval for this granularity (default: 40.0) - -### Pre-defined Ranges - -You can use the default auto-interval ranges: - -```dart -import 'package:deriv_chart/deriv_chart.dart'; - -// Uses the default trading timeframes configuration -zoomRanges: defaultAutoIntervalRanges, -``` - -The default ranges include common trading timeframes from 1 minute to 1 day. - -## Integration with State Management - -```dart -class ChartWidget extends StatefulWidget { - @override - _ChartWidgetState createState() => _ChartWidgetState(); -} - -class _ChartWidgetState extends State { - int currentGranularity = 300000; // 5 minutes - List chartData = []; - bool autoIntervalEnabled = true; - - @override - Widget build(BuildContext context) { - Widget chartContent = DerivChart( - mainSeries: DataSeries(chartData), - granularity: currentGranularity, - activeSymbol: 'EURUSD', - // ... other properties - ); - - // Conditionally wrap with auto-interval if enabled - if (autoIntervalEnabled) { - chartContent = AutoIntervalWrapper( - enabled: true, - granularity: currentGranularity, - onGranularityChangeRequested: _handleGranularityChange, - child: chartContent, - ); - } - - return chartContent; - } - - void _handleGranularityChange(int suggestedGranularity) { - final int suggestedSeconds = suggestedGranularity ~/ 1000; - - if (suggestedSeconds != currentGranularity ~/ 1000) { - setState(() { - currentGranularity = suggestedGranularity; - }); - - // Fetch new data with the suggested granularity - _fetchChartData(suggestedSeconds); - } - } - - Future _fetchChartData(int granularitySeconds) async { - // Your data fetching logic here - final newData = await fetchTickData(granularitySeconds); - setState(() { - chartData = newData; - }); - } -} -``` - -## Benefits - -1. **Ultra Simple**: Single wrapper widget with all logic built-in -2. **No Dependencies**: No manager classes or complex setup -3. **Consistent Pattern**: Follows the same pattern as other chart wrappers -4. **Minimal Code**: Fewer files and simpler architecture -5. **Easy Integration**: Works with both `Chart` and `DerivChart` widgets -6. **Better Performance**: Direct calculations without extra abstraction layers - -## Architecture - -### Simplified Design -``` -AutoIntervalWrapper (all-in-one) -├── ZoomLevelObserver implementation -├── Granularity calculation logic -├── State management (current/last suggested) -└── Provider for children -``` - -### What's Inside AutoIntervalWrapper -- **State Tracking**: Current granularity and last suggestion -- **Calculation Logic**: Optimal granularity based on zoom ranges -- **Observer Interface**: Implements `ZoomLevelObserver` directly -- **Provider Integration**: Exposes itself to chart components - -## Migration from Old Approach - -If you were using the old `ChartAxisConfig.autoIntervalEnabled` approach: - -### Before (Old Way) -```dart -Chart( - // ... properties - chartAxisConfig: ChartAxisConfig( - autoIntervalEnabled: true, - autoIntervalZoomRanges: yourRanges, - ), - onGranularityChangeRequested: handleChange, -) -``` - -### After (New Simplified Way) -```dart -AutoIntervalWrapper( - enabled: true, - granularity: currentGranularity, - zoomRanges: yourRanges, - onGranularityChangeRequested: handleChange, - child: Chart( - // ... properties - chartAxisConfig: ChartAxisConfig( - // autoIntervalEnabled no longer needed - ), - ), -) -``` - -## Technical Notes - -- **No AutoIntervalManager**: All logic is directly in the wrapper state -- **Stateful Widget**: Manages granularity state and calculations internally -- **Provider Pattern**: Exposes `ZoomLevelObserver` interface to children -- **Automatic Cleanup**: No manual disposal needed - Flutter handles it -- **Reactive Updates**: Responds to granularity changes via `didUpdateWidget` - -The wrapper approach provides maximum simplicity with minimal overhead! diff --git a/doc/adaptive_interval.md b/doc/adaptive_interval.md new file mode 100644 index 000000000..bf9d6cc41 --- /dev/null +++ b/doc/adaptive_interval.md @@ -0,0 +1,120 @@ +# Adaptive Interval (Auto-Interval) Feature + +## Overview + +The **adaptive interval** (also called **auto-interval**) feature automatically adjusts the chart's time granularity (interval) based on the current zoom level. This ensures that the chart remains readable and usable at all zoom levels, without requiring the user to manually select the most appropriate interval. + +When enabled, the chart will suggest and can switch to the most suitable granularity as the user zooms in or out, based on a configurable mapping between zoom levels and time intervals. + +--- + +## Key Components + +- **`AutoIntervalWrapper`**: A widget that observes zoom level changes and manages the logic for suggesting granularity changes. +- **`ZoomLevelObserver`**: An interface for components that need to react to zoom level changes. +- **`AutoIntervalZoomRange`**: A configuration class that maps each granularity (in milliseconds) to its optimal pixel range on the chart. +- **`ChartAxisConfig`**: Holds the configuration for enabling/disabling auto-interval and its zoom ranges. + +--- + +## How It Works + +1. **Zoom Level Observation**: The chart's x-axis model notifies the `AutoIntervalWrapper` of zoom level changes (in milliseconds per pixel). +2. **Optimal Granularity Calculation**: The wrapper calculates the optimal granularity for the current zoom level using the configured `AutoIntervalZoomRange` list. +3. **Granularity Suggestion**: If a different granularity is optimal and hasn't already been suggested, the wrapper triggers the `onGranularityChangeRequested` callback. +4. **Granularity Update**: The parent widget or chart controller can then update the chart's granularity, which will fetch and display data at the new interval. + +--- + +## Configuration + +- **Enable/Disable**: Set `autoIntervalEnabled` in `ChartAxisConfig` to `true` or `false`. +- **Customize Ranges**: Modify `autoIntervalZoomRanges` in `ChartAxisConfig` to change which granularities are used at which zoom levels. +- **Default Ranges**: The default configuration covers common trading timeframes (1m, 2m, 5m, etc.), each with a pixel range for when it should be used. + +Example: +```dart +ChartAxisConfig( + autoIntervalEnabled: true, + autoIntervalZoomRanges: [ + AutoIntervalZoomRange( + granularity: 60000, // 1 minute + minPixelsPerInterval: 12, + maxPixelsPerInterval: 24, + ), + // ... more ranges ... + ], +) +``` + +--- + +## Usage Example + +Wrap your chart widget with `AutoIntervalWrapper`: + +```dart +AutoIntervalWrapper( + enabled: true, + granularity: currentGranularity, // in milliseconds + zoomRanges: autoIntervalRanges, // List + onGranularityChangeRequested: (newGranularity) { + // Update chart granularity and fetch new data + }, + child: ... // your chart widget +) +``` + +- The `onGranularityChangeRequested` callback is called when the wrapper suggests a new optimal granularity. +- You are responsible for updating the chart's granularity and fetching new data as needed. + +--- + +## Internal Logic + +- The wrapper listens for zoom level changes (ms per pixel) via the `ZoomLevelObserver` interface. +- For each zoom event, it calculates the number of pixels each interval (candle) would occupy at the current zoom. +- It checks all configured `AutoIntervalZoomRange` entries to find which range the current zoom fits into, and selects the one closest to its optimal pixel width. +- If the optimal granularity is different from the current one and hasn't already been suggested, it triggers the callback. + +--- + +## Example Integration + +```dart +ChartAxisConfig config = ChartAxisConfig( + autoIntervalEnabled: true, +); + +Chart( + chartAxisConfig: config, + granularity: granularity, + onGranularityChangeRequested: (int suggestedGranularity) { + // Convert ms to seconds for API call if needed + final int seconds = suggestedGranularity ~/ 1000; + if (seconds != granularity) { + // Update granularity and fetch new data + setState(() => granularity = seconds); + fetchDataWithGranularity(seconds); + } + }, + // ... other chart params ... +) +``` + +--- + +## Notes + +- The adaptive interval feature is especially useful for financial charts where users frequently zoom in and out to analyze data at different timeframes. +- You can fully customize the mapping between zoom levels and granularities to suit your application's needs. +- The feature is opt-in and can be toggled at runtime. + +--- + +## Related Classes and Files +- `lib/src/deriv_chart/chart/auto_interval/auto_interval_wrapper.dart` +- `lib/src/deriv_chart/chart/auto_interval/zoom_level_observer.dart` +- `lib/src/models/chart_axis_config.dart` +- `lib/src/deriv_chart/chart/x_axis/x_axis_model.dart` +- Example usage: `example/lib/main.dart` diff --git a/lib/src/deriv_chart/chart/auto_interval/auto_interval_wrapper.dart b/lib/src/deriv_chart/chart/auto_interval/auto_interval_wrapper.dart index 411098463..b340059af 100644 --- a/lib/src/deriv_chart/chart/auto_interval/auto_interval_wrapper.dart +++ b/lib/src/deriv_chart/chart/auto_interval/auto_interval_wrapper.dart @@ -7,8 +7,7 @@ import '../../../models/chart_axis_config.dart'; /// /// This widget wraps chart components and automatically manages granularity /// changes based on zoom levels. It provides a clean interface similar to -/// GestureManager and XAxisWrapper, eliminating the need for manual coordinator -/// setup and observer pattern implementation. +/// GestureManager and XAxisWrapper. /// /// Usage: /// ```dart @@ -19,7 +18,7 @@ import '../../../models/chart_axis_config.dart'; /// onGranularityChangeRequested: (newGranularity) { /// // Handle granularity change request /// }, -/// child: Chart(...), +/// child: ... /// ) /// ``` class AutoIntervalWrapper extends StatefulWidget { From 37652b4daa5db2bb6049e685bdbcd45e6e3e1344 Mon Sep 17 00:00:00 2001 From: behnam-deriv <133759298+behnam-deriv@users.noreply.github.com> Date: Wed, 18 Jun 2025 13:32:15 +0800 Subject: [PATCH 7/7] chore: dart format --- .../deriv_chart/chart/auto_interval/auto_interval_wrapper.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/src/deriv_chart/chart/auto_interval/auto_interval_wrapper.dart b/lib/src/deriv_chart/chart/auto_interval/auto_interval_wrapper.dart index b340059af..947aa33cd 100644 --- a/lib/src/deriv_chart/chart/auto_interval/auto_interval_wrapper.dart +++ b/lib/src/deriv_chart/chart/auto_interval/auto_interval_wrapper.dart @@ -99,7 +99,6 @@ class AutoIntervalWrapperState extends State double bestScore = double.infinity; for (final AutoIntervalZoomRange range in widget.zoomRanges) { - // The number of pixels that one interval (candle) will occupy on the // chart at current zoom level. final double pixelsPerInterval = range.granularity / msPerPx;