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/example/lib/main.dart b/example/lib/main.dart index e97a55d72..649c5ef4b 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,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 { @@ -433,6 +438,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 @@ -450,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 { @@ -562,20 +613,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)), ], ), ) @@ -754,7 +800,9 @@ class _FullscreenChartState extends State { _requestCompleter = Completer(); setState(() { - ticks.clear(); + if (!_adaptiveInterval) { + ticks.clear(); + } _clearMarkers(); _clearBarriers(); }); @@ -843,4 +891,35 @@ class _FullscreenChartState extends State { authEndpoint: '', ); } + + Widget _buildToggle(String label, bool value, VoidCallback onToggle) { + return Expanded( + child: CheckboxListTile( + title: Text( + label, + style: const TextStyle(fontSize: 14), + ), + value: value, + onChanged: (_) => onToggle(), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + dense: true, + ), + ); + } + + // 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..947aa33cd --- /dev/null +++ b/lib/src/deriv_chart/chart/auto_interval/auto_interval_wrapper.dart @@ -0,0 +1,151 @@ +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. +/// +/// Usage: +/// ```dart +/// AutoIntervalWrapper( +/// enabled: true, +/// granularity: currentGranularity, +/// zoomRanges: autoIntervalRanges, +/// onGranularityChangeRequested: (newGranularity) { +/// // Handle granularity change request +/// }, +/// child: ... +/// ) +/// ``` +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 { + late int _currentGranularity; + double? _currentMsPerPx; + 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 + } + + // Suggest granularity change if auto-interval is re-enabled. + if (!oldWidget.enabled && widget.enabled) { + _requestGranularityChangeIfNeeded(); + } + } + + @override + void onZoomLevelChanged(double msPerPx, int currentGranularity) { + // Update current granularity and msPerPx + _currentGranularity = currentGranularity; + _currentMsPerPx = msPerPx; + + // Calculate optimal granularity for current zoom level if auto-interval + // is enabled. + if (widget.enabled) { + _requestGranularityChangeIfNeeded(); + } + } + + /// 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) { + // 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 + 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; + } + + /// 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( + 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..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,21 +452,33 @@ class BasicChartState extends State builder: (BuildContext context, _) => RepaintBoundary( child: Opacity( opacity: widget.opacity, - child: CustomPaint( - painter: ChartDataPainter( - animationInfo: AnimationInfo( - currentTickPercent: currentTickAnimation.value, + child: MaybeAnimatedSwitcher( + enabled: context + .watch() + .chartAxisConfig + .autoIntervalEnabled, + duration: context + .watch() + .chartAxisConfig + .autoIntervalTransitionDuration, + 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..61a50cf9b 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. @@ -307,30 +316,36 @@ abstract class _ChartState extends State with WidgetsBindingObserver { final Duration currentTickAnimationDuration = widget.currentTickAnimationDuration ?? _defaultDuration; - return 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), + ), ), ), ), 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..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 @@ -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. /// @@ -113,8 +114,14 @@ 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.zoomLevelObserver = zoomLevelObserver; + gestureManager = context.read() ..registerCallback(_model.onScaleAndPanStart) ..registerCallback(_model.onScaleUpdate) @@ -140,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 b5614cd63..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 @@ -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]. @@ -49,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 @@ -89,6 +90,8 @@ class XAxisModel extends ChangeNotifier { _dataFitPadding = dataFitPadding ?? defaultDataFitPadding; + _autoIntervalEnabled = autoIntervalEnabled; + _updateEntries(entries); _scrollAnimationController = animationController @@ -104,6 +107,8 @@ class XAxisModel extends ChangeNotifier { }); } + late bool _autoIntervalEnabled; + late double _minIntervalWidth; late double _maxIntervalWidth; @@ -329,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. @@ -470,12 +477,28 @@ 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) + set zoomLevelObserver(ZoomLevelObserver? observer) { + _zoomLevelObserver = observer; } - /// Called to scale the chart + /// 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); onScale?.call(); + if (kIsWeb) { + zoomLevelObserver?.onZoomLevelChanged(_msPerPx, _granularity); + } notifyListeners(); } @@ -561,6 +584,7 @@ class XAxisModel extends ChangeNotifier { int? maxEpoch, EdgeInsets? dataFitPadding, double? maxCurrentTickOffset, + bool? autoIntervalEnabled, }) { _updateIsLive(isLive); _updateGranularity(granularity); @@ -570,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. 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..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,6 +11,97 @@ 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 = 18.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: 12, + maxPixelsPerInterval: 24, + ), // 1 minute + AutoIntervalZoomRange( + granularity: 120000, + minPixelsPerInterval: 12, + maxPixelsPerInterval: 24, + ), // 2 minutes + AutoIntervalZoomRange( + granularity: 180000, + minPixelsPerInterval: 12, + maxPixelsPerInterval: 24, + ), // 3 minutes + AutoIntervalZoomRange( + granularity: 300000, + minPixelsPerInterval: 12, + maxPixelsPerInterval: 24, + ), // 5 minutes + AutoIntervalZoomRange( + granularity: 600000, + minPixelsPerInterval: 12, + maxPixelsPerInterval: 24, + ), // 10 minutes + AutoIntervalZoomRange( + granularity: 900000, + minPixelsPerInterval: 12, + maxPixelsPerInterval: 24, + ), // 15 minutes + AutoIntervalZoomRange( + granularity: 1800000, + minPixelsPerInterval: 12, + maxPixelsPerInterval: 24, + ), // 30 minutes + AutoIntervalZoomRange( + granularity: 3600000, + minPixelsPerInterval: 12, + maxPixelsPerInterval: 24, + ), // 1 hour + AutoIntervalZoomRange( + granularity: 7200000, + minPixelsPerInterval: 12, + maxPixelsPerInterval: 24, + ), // 2 hours + AutoIntervalZoomRange( + granularity: 14400000, + minPixelsPerInterval: 12, + maxPixelsPerInterval: 24, + ), // 4 hours + AutoIntervalZoomRange( + granularity: 28800000, + minPixelsPerInterval: 12, + maxPixelsPerInterval: 24, + ), // 8 hours + AutoIntervalZoomRange( + granularity: 86400000, + minPixelsPerInterval: 12, + maxPixelsPerInterval: 24, + ), // 1 day +]; + /// Configuration for the chart axis. @immutable class ChartAxisConfig { @@ -23,6 +115,9 @@ class ChartAxisConfig { this.showEpochGrid = true, this.showFrame = false, this.smoothScrolling = true, + this.autoIntervalEnabled = false, + this.autoIntervalZoomRanges = defaultAutoIntervalRanges, + this.autoIntervalTransitionDuration = CoreDesignTokens.motionDurationSnappy, }); /// Top quote bound target for animated transition. @@ -60,16 +155,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, ); } 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; + } +}