Skip to content

fix: keep data anchored to the left edge in data-fit mode - #492

Merged
behnam-deriv merged 2 commits into
masterfrom
anchor-data-to-left-in-data-fit-mode
Aug 12, 2026
Merged

fix: keep data anchored to the left edge in data-fit mode#492
behnam-deriv merged 2 commits into
masterfrom
anchor-data-to-left-in-data-fit-mode

Conversation

@behnam-deriv

@behnam-deriv behnam-deriv commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

What

For charts started in data-fit mode, the oldest entry now stays anchored to the left edge, and zooming out stops at the data-fit scale.

Three changes in XAxisModel:

  1. New _anchorDataToLeft flag, set from startWithDataFitMode. Unlike _dataFitMode — which any pan or scale gesture turns off — this stays on for the lifetime of the model.
  2. _minRightBoundEpoch now returns the rightBoundEpoch that puts the oldest entry exactly where data-fit mode would (_dataFitPadding.left px from the left edge), instead of a bound derived from maxCurrentTickOffset.
  3. scale() clamps against _scaleOutMsPerPxLimit — the data-fit scale — rather than _maxMsPerPx.

When the data no longer spans the viewport, the left anchor wins over the maxCurrentTickOffset upper bound.

Why

These charts show a bounded data window (e.g. contract details), not a live stream, so the old behaviour was wrong in two ways:

  • Panning or zooming let the oldest entry drift right of its data-fit position, leaving a growing gap on the left.
  • Zooming out past the data-fit scale revealed nothing — the data just shrank into a band at the left edge, and a live chart lost the ability to follow the current tick.

Scope

Only affects charts constructed with startWithDataFitMode: true. Charts that start in follow-current-tick mode keep the existing bounds and zoom limits.

🤖 Generated with Claude Code

Summary by Sourcery

Ensure charts initialized in data-fit mode keep their data anchored to the left edge and prevent zooming out beyond the data-fit scale.

Bug Fixes:

  • Prevent oldest data points in data-fit charts from drifting right and leaving a gap on the left when panning or zooming.
  • Stop users from zooming out past the data-fit scale when all data already fits in the viewport, avoiding useless extra zoom.

Enhancements:

  • Introduce a persistent left-anchor mode for bounded data-window charts that overrides tick-offset bounds when data no longer spans the viewport.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

OpenSSF Scorecard

PackageVersionScoreDetails

Scanned Manifest Files

@sourcery-ai

sourcery-ai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

Adds a persistent left-anchor behavior for charts initialized in data-fit mode, updates scroll bounds to respect that anchor, and clamps zoom-out to the data-fit scale so bounded-data charts keep their oldest entry fixed at the left edge and never zoom out past the point where all data already fits on screen.

File-Level Changes

Change Details Files
Introduce a persistent flag to mark charts whose data should stay anchored to the left edge when started in data-fit mode.
  • Add _anchorDataToLeft as a late bool field on the model with documentation explaining its semantics versus _dataFitMode.
  • Initialize _anchorDataToLeft from startWithDataFitMode in the constructor so it remains true for the lifetime of the model for bounded-data charts.
lib/src/deriv_chart/chart/x_axis/x_axis_model.dart
Rework the minimum right-bound epoch calculation so that, when left-anchoring is active, the oldest entry stays at the same position data-fit mode would place it instead of drifting right based on maxCurrentTickOffset.
  • Replace the _minRightBoundEpoch getter logic to prefer a data-fit-derived bound when _anchorDataToLeft is true and width is available.
  • Fallback to the previous maxCurrentTickOffset-based lower bound when anchoring is not active or layout information is missing.
  • Add a reconciliation branch in _reconcileBounds that, when the lower bound exceeds the upper bound in left-anchored charts, forces rightBoundEpoch to the lower bound so the left anchor wins once data no longer spans the viewport.
lib/src/deriv_chart/chart/x_axis/x_axis_model.dart
Introduce a derived zoom-out limit tied to the data-fit scale and use it to clamp user scaling so charts with bounded data cannot zoom out past the point where all data fits.
  • Add _scaleOutMsPerPxLimit getter that computes the ms-per-px value corresponding to the data-fit scale based on entries length, granularity, and available width minus padding, with clamping to the existing min/max zoom range.
  • Update scale() to clamp _msPerPx using _scaleOutMsPerPxLimit as the upper bound instead of _maxMsPerPx, thereby restricting zoom-out for left-anchored charts while preserving previous behavior for others.
lib/src/deriv_chart/chart/x_axis/x_axis_model.dart

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

Claude encountered an error —— View job


I'll analyze this and get back to you.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've left some high level feedback:

  • In _scaleOutMsPerPxLimit, using _entries!.length * granularity assumes no gaps and consistent granularity; consider basing the duration on _maxEpoch - _minEpoch so the zoom limit reflects the actual data span.
  • The _anchorDataToLeft behavior is currently tied only to startWithDataFitMode; if future use cases require toggling this at runtime, consider exposing a controlled way to change it rather than keeping it strictly late and immutable.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `_scaleOutMsPerPxLimit`, using `_entries!.length * granularity` assumes no gaps and consistent granularity; consider basing the duration on `_maxEpoch - _minEpoch` so the zoom limit reflects the actual data span.
- The `_anchorDataToLeft` behavior is currently tied only to `startWithDataFitMode`; if future use cases require toggling this at runtime, consider exposing a controlled way to change it rather than keeping it strictly `late` and immutable.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

Claude encountered an error —— View job


I'll analyze this and get back to you.

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

Claude finished @behnam-deriv's task in 6m 27s —— View job


I'll analyze this and get back to you.

@github-actions

Copy link
Copy Markdown

🤖 Claude PR Review Complete

Model: claude-sonnet-5 | Review Type: 📋 Initial Review

Summary

This PR makes a focused, well-reasoned change to XAxisModel: charts started with startWithDataFitMode: true now get a persistent _anchorDataToLeft flag that (1) redefines the scroll lower-bound to keep the oldest entry pinned at its data-fit position, and (2) caps user zoom-out at the data-fit scale. I traced the math in _minRightBoundEpoch, _scaleOutMsPerPxLimit, and the new _clampRightBoundEpoch branch against _fitData() and confirmed the formulas are consistent, and that the change is properly scoped — charts with startWithDataFitMode: false (the default, live-streaming path) get _scaleOutMsPerPxLimit == _maxMsPerPx and the original _maxCurrentTickOffset-based bound, i.e. unchanged behavior. No correctness or security issues found. The main gap is test coverage.

Recommendation: APPROVE


🟡 Medium Priority Issues

🟡 1. No test coverage for the new left-anchor / zoom-out-limit behaviorlib/src/deriv_chart/chart/x_axis/x_axis_model.dart:209-264,619-629

Details

Severity File Lines
MEDIUM lib/src/deriv_chart/chart/x_axis/x_axis_model.dart 209-264, 619-629

❌ Problematic Code:

The three new pieces of logic (_minRightBoundEpoch left-anchor branch, _scaleOutMsPerPxLimit, and the _clampRightBoundEpoch left-anchor fallback) ship with no accompanying test. This repo already has a pattern for constructing XAxisModel directly in tests (test/deriv_chart/chart/interactive_layer/interactive_layer_sync_test.dart:47-57), so this is straightforward to add.

📋 Issue: This is bound/scroll-limit math with several interacting edge cases (sparse data, width == null, _minRightBoundEpoch > _maxRightBoundEpoch). Without a test, a future refactor of _fitData, _maxCurrentTickOffset, or _dataFitPadding handling could silently reintroduce the "gap on the left" or "zoom out past data-fit" bugs this PR fixes.

⚠️ Impact: Regressions in this area would only surface visually (chart drifting or over-zooming) and are easy to miss in manual QA on a bounded-data (contract details) chart.

✅ Fix:

test('anchors oldest entry to the left edge and blocks zoom past data-fit scale', () {
  final XAxisModel model = XAxisModel(
    entries: ticks, // e.g. 20 ticks, granularity 1000
    granularity: 1000,
    animationController: animationController,
    isLive: false,
    snapMarkersToIntervals: false,
    maxCurrentTickOffset: 150,
    startWithDataFitMode: true,
  )
    ..width = 800
    ..graphAreaWidth = 740;

  model.fitAvailableData();
  final double fitMsPerPx = model.msPerPx;

  // Panning right should not push the oldest entry past its data-fit
  // position.
  model.scrollBy(100000);
  expect(model.xFromEpoch(ticks.first.epoch),
      closeTo(defaultDataFitPadding.left, 1));

  // Zooming out should not exceed the data-fit scale.
  model.onScaleAndPanStart(ScaleStartDetails());
  model.scale(0.01); // large zoom-out factor
  expect(model.msPerPx, lessThanOrEqualTo(fitMsPerPx));
});

💡 Explanation: Asserting on xFromEpoch/msPerPx (public API) rather than the private fields keeps the test resilient to internal refactors while still pinning down the externally-visible behavior this PR guarantees.


🟢 Low Priority Issues

🟢 2. Data-fit scale expression duplicated between _fitData() and _scaleOutMsPerPxLimitlib/src/deriv_chart/chart/x_axis/x_axis_model.dart:252-264,411-425

Details

Severity File Lines
LOW lib/src/deriv_chart/chart/x_axis/x_axis_model.dart 252-264, 411-425

❌ Problematic Code:

// _scaleOutMsPerPxLimit (new)
final double pxTargetDataWidth = width! - _dataFitPadding.horizontal;
if (pxTargetDataWidth > 0) {
  final int msDataDuration = _entries!.length * granularity;
  return (msDataDuration / pxTargetDataWidth).clamp(_minMsPerPx, _maxMsPerPx);
}

// _fitData (existing)
final int msDataDuration = _entries!.length * granularity;
final double pxTargetDataWidth = width! - _dataFitPadding.horizontal;
_msPerPx = (msDataDuration / pxTargetDataWidth).clamp(_minMsPerPx, _maxMsPerPx);

📋 Issue: The doc comment on _scaleOutMsPerPxLimit ("Same expression as _fitData, so the limit lands exactly on the data-fit scale") calls out the coupling explicitly, but the expression itself is now copy-pasted in two places. Sourcery's review also flagged that _entries!.length * granularity ignores gaps in the data (weekends/off-hours) rather than using the actual _maxEpoch - _minEpoch span — a fair point, but since it mirrors _fitData's existing approximation, fixing only one side would make them diverge, which is worse than the status quo.

⚠️ Impact: Low risk today, but if either copy is tweaked in isolation in a future change, the zoom-out limit and the actual data-fit scale will silently drift apart, reintroducing the very bug this PR fixes (zooming out past the point where data fits).

✅ Fix:

/// Ms-per-px scale that fits all of [_entries] within
/// `width - _dataFitPadding.horizontal` px, clamped to the zoom range.
double? _dataFitMsPerPx() {
  if (width == null || !(_entries?.isNotEmpty ?? false)) {
    return null;
  }
  final double pxTargetDataWidth = width! - _dataFitPadding.horizontal;
  if (pxTargetDataWidth <= 0) {
    return null;
  }
  final int msDataDuration = _entries!.length * granularity;
  return (msDataDuration / pxTargetDataWidth).clamp(_minMsPerPx, _maxMsPerPx);
}

double get _scaleOutMsPerPxLimit =>
    _anchorDataToLeft ? (_dataFitMsPerPx() ?? _maxMsPerPx) : _maxMsPerPx;

// in _fitData():
final double? fitMsPerPx = _dataFitMsPerPx();
if (fitMsPerPx != null) {
  _msPerPx = fitMsPerPx;
  _scrollTo(_shiftEpoch(lastEntryEpoch, _dataFitPadding.right));
}

💡 Explanation: Extracting the shared calculation into one helper guarantees _scaleOutMsPerPxLimit and _fitData() can never disagree on what "the data-fit scale" is, and gives a single place to later switch to a gap-aware duration if that's ever needed.


Summary Table

Priority Count Categories
🔴 Critical 0
🟠 High 0
🟡 Medium 1 Missing test coverage
🟢 Low 1 Duplicated data-fit scale calculation

Recommendations

  • Add a unit test exercising startWithDataFitMode: true through the public API (fitAvailableData, scrollBy, scale) to lock in the left-anchor and zoom-out-limit behavior described in the PR body.
  • Consider extracting the shared "data-fit ms-per-px" expression into one helper used by both _fitData() and _scaleOutMsPerPxLimit to prevent future drift between the two.
  • Confirmed no impact on the default (startWithDataFitMode: false) live-chart path, and no impact on the manually-toggled "fit data" button on live charts (main_chart.dart:355) — _anchorDataToLeft stays false for those, which matches the PR's stated scope.

Auto Fix Claude Reviews

Action Open Dashboard

@behnam-deriv
behnam-deriv merged commit 9f42d83 into master Aug 12, 2026
7 of 8 checks passed
@behnam-deriv
behnam-deriv deleted the anchor-data-to-left-in-data-fit-mode branch August 12, 2026 06:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant