feat: add animationMatchBy prop for identity-based animation#6973
feat: add animationMatchBy prop for identity-based animation#6973robjampar wants to merge 1 commit into
Conversation
WalkthroughAdds a new point-matching and interpolation module plus two interpolation presets; exposes an animationStepFunction prop on Area and Line; updates public exports, docs, storybook demo, and adds comprehensive tests for matching and animation behavior. Changes
Sequence Diagram(s)sequenceDiagram
participant App
participant LineComponent as Line Component
participant Matcher as Point Matcher
participant Interpolator as Interpolator
App->>LineComponent: update data points
LineComponent->>Matcher: matchPointsByStrategy(current, previous, strategy)
Matcher-->>LineComponent: matched points mapping
loop frames (t from 0 to 1)
alt animationStepFunction provided
LineComponent->>Interpolator: animationStepFunction(current, previous, t)
Interpolator-->>LineComponent: stepPoints (interpolated)
else
LineComponent->>LineComponent: fallback per-point interpolation
end
LineComponent-->>App: render intermediate frame
end
LineComponent-->>App: render final frame
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
19d7f57 to
3595103
Compare
There was a problem hiding this comment.
Actionable comments posted: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
test/component/Legend.itemSorter.spec.tsx (1)
58-142:⚠️ Potential issue | 🟡 MinorUse
expectLastCalledWithfor the legend payload assertions.These two expectations still use
toHaveBeenLastCalledWith, but the repo standard is theexpectLastCalledWithhelper for better typing and consistency.♻️ Suggested change
- expect(customContent).toHaveBeenLastCalledWith( - { + expectLastCalledWith( + customContent, + { align: 'center', chartHeight: 500, chartWidth: 500, content: customContent, @@ - }, - {}, - ); + }, + {}, + ); @@ - expect(customContent).toHaveBeenLastCalledWith( - { + expectLastCalledWith( + customContent, + { align: 'center', chartHeight: 500, chartWidth: 500, content: customContent, @@ - }, - {}, - ); + }, + {}, + );As per coding guidelines, use the
expectLastCalledWithhelper function instead ofexpect(spy).toHaveBeenLastCalledWith(...).Also applies to: 156-241
🤖 Fix all issues with AI agents
In `@src/animation/pointMatching.ts`:
- Around line 155-163: The function getComparisonValue currently calls a custom
PointMatchingStrategy with a hardcoded index (strategy(point, 0)), which can
break user-provided strategies that depend on the true point index; update
getComparisonValue to accept an index parameter (e.g., add idx: number) and pass
that index into the strategy call, updating all callers of getComparisonValue
accordingly (referencing getComparisonValue, PointMatchingStrategy, and
AnimatablePoint), or alternatively document in the type/comment that the index
is unavailable—prefer the index-parameter approach so strategy(point, idx)
receives the real array index.
In `@src/cartesian/ReferenceArea.tsx`:
- Around line 119-128: In ReferenceArea.tsx (and similarly ReferenceLine.tsx)
replace the inline union for the animationEasing prop and the numeric type for
animationDuration with the shared types: import and use AnimationTiming for
animationEasing and AnimationDuration for animationDuration (matching Line and
Area components), and update the JSDoc comments to the expanded descriptive
style (e.g., "The type of easing function." for animationEasing and a
descriptive comment for animationDuration) so the prop signatures and docs match
existing components.
- Line 2: The code imports useId from 'react' which breaks React 16/17; update
the import to use the project's custom hook instead: replace the React import of
useId with the local useId hook (the implementation in src/util/useId.ts) in the
ReferenceArea and ReferenceLine components so they call the custom useId
fallback rather than React's built-in useId.
In `@test/animation/pointMatching.spec.ts`:
- Around line 1-687: The test suite is missing unit tests for two exported
functions used by the animation pipeline: matchPointsWithRemovals and
interpolateMatchedPoints; add focused tests that verify (1)
matchPointsWithRemovals correctly drops unmatched previous points, preserves
ordering, and returns data structures expected by the interpolator (use the
function name matchPointsWithRemovals to locate it), and (2)
interpolateMatchedPoints performs frame-by-frame interpolation including correct
extrapolated entry positions for new points and proper handling of removed
points (use interpolateMatchedPoints to locate it); mimic existing test patterns
for strategy='x' and 'index', assert both matched.previous references and
resulting interpolated frames, and add a few integration-style cases that mirror
how Line.tsx and Area.tsx consume these functions (sliding windows, bulk
add/remove, single-point replace) to cover edge behaviors.
In `@test/cartesian/Line.animation.spec.tsx`:
- Around line 1631-1802: Add concrete assertions that verify
animationMatchBy="x" behavior by inspecting rendered point positions and/or the
SVG path at t=0, mid-animation, and t=1: in each test use the existing
renderTestCase and prime helpers plus
animationManager.setAnimationProgress/completeAnimation to snapshot coordinates
(querySelectorAll('.recharts-dot') or the path 'd' via
querySelector('.recharts-line-curve')) and assert that existing items (e.g.,
symbols named B and C) start from their previous x coordinates and end at their
new x coordinates, and that newly added points (D or A) begin outside the chart
on the correct side (right for append/shift-in, left for prepend) and end
inside; add these assertions at the same places where the tests currently call
setAnimationProgress(0.5) and completeAnimation() to validate t=0, t=0.5, and
t=1 behavior.
- Around line 1631-1660: Add fake timers for the animation tests by inserting a
beforeEach that calls vi.useFakeTimers() and an afterEach that calls
vi.useRealTimers() within the same describe block that contains
renderTestCase/shiftData (the block using initialData, shiftedData, LineChart
and Line with animationMatchBy="x"); this ensures requestAnimationFrame-based
animations are deterministic during tests and you can then advance timers (e.g.,
vi.advanceTimersByTime) when clicking the Shift data button to assert animation
states.
🧹 Nitpick comments (9)
src/cartesian/GraphicalItemClipPath.tsx (1)
16-16: Consider adding a JSDoc comment for theforceClipprop.Since
GraphicalItemClipPathis part of the public type surface (listed intypesFiles.txt), the newforceClipprop would benefit from a brief JSDoc description to support auto-generated API docs.As per coding guidelines: "JSDoc comments and TypeScript definitions in source files must be kept up-to-date as they are used to autogenerate API documentation via 'npm run omnidoc'."
src/animation/pointMatching.ts (3)
10-15: Clarify JSDoc for'x'strategy — it matches by payload key, not geometric coordinate.The JSDoc says
'x' matches by coordinate, but tracing throughgetPointKey, when strategy is'x', it falls through to the DataKey branch and looks uppayload.xviagetValueByDataKey— not the point's geometricxproperty. While these often coincide, the distinction matters when they diverge (e.g., derived/computed coordinates). Consider clarifying the JSDoc:-/** 'index' preserves legacy behavior; 'x' matches by coordinate; a DataKey or function matches by identity. */ +/** 'index' preserves legacy behavior; 'x' matches by the 'x' key in the data payload; a DataKey or function matches by identity. */
262-322:interpolateMatchedPoints— solid interpolation logic with a few observations.The function handles three cases well: matched points (interpolated), new points (entry from edge), and removed points (slide out). The early-return at
t === 1is a good optimization.Two minor notes:
- Line 251:
avgSpacingdefaults to100when there's only one point — consider documenting or extracting this magic number.- Line 302: new points don't interpolate
y— they appear at their final y-position immediately. This is intentional for horizontal line charts but may look odd for vertical layouts. Worth a comment explaining the design choice.
50-68:buildPreviousPointsMap— first-occurrence-wins on duplicate keys.Line 61's
if (!map.has(key))means only the first occurrence of a duplicate key is kept. This is a valid design choice but might surprise users with non-unique keys. Consider adding a brief JSDoc note about this behavior.src/cartesian/ReferenceArea.tsx (2)
197-199:lerphelper is duplicated betweenReferenceArea.tsxandReferenceLine.tsx.Both files define identical
lerpfunctions. Consider extracting this to a shared utility (e.g.,src/util/DataUtils.tswhereinterpolatealready lives, or a new animation utility).
261-287: Interrupted animations will jump to the previous target position.When a new rect arrives while an animation is in progress,
previousRectRef.currenthas already been set to the target of the in-flight animation (line 267), not its current interpolated position. This means a mid-animation data change will cause a visual discontinuity — the new animation starts from where the old one was heading, not from where it currently is.Compare with
Line.tsx's approach wherepreviousPointsRef.currentis updated inside the animation callback (line 706) with the interpolatedstepData, enabling smooth interruption.This is likely acceptable for ReferenceArea (which changes less frequently), but worth noting if smooth interruption is desired.
src/cartesian/ReferenceLine.tsx (3)
5-5: SameuseIdimport concern asReferenceArea.tsx.Consider using the custom
useIdfromsrc/util/useId.tsfor backward compatibility with React < 18.
286-290:lerpis duplicated (also inReferenceArea.tsx) with a slightly different signature.This version accepts
number | null | undefinedwhile ReferenceArea's acceptsnumber | undefined. Consider extracting a singlelerputility with the broader signature.
130-138: Animation props use inline union type instead ofAnimationTiming.Same as the ReferenceArea comment —
animationEasingis typed as'ease' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'linear'rather than reusingAnimationTimingfromsrc/util/types.ts. This creates a maintenance risk if the timing options are extended.Proposed fix
+ import { AnimationTiming } from '../util/types'; // In ReferenceLineProps: - animationEasing?: 'ease' | 'ease-in' | 'ease-out' | 'ease-in-out' | 'linear'; + animationEasing?: AnimationTiming;Apply the same change in
ReferenceArea.tsx.Also applies to: 412-415
| function getComparisonValue<T extends AnimatablePoint>(point: T, strategy: PointMatchingStrategy<T>): number | null { | ||
| if (strategy === 'index') { | ||
| return point.x; | ||
| } | ||
|
|
||
| if (typeof strategy === 'function') { | ||
| const key = strategy(point, 0); | ||
| return typeof key === 'number' ? key : null; | ||
| } |
There was a problem hiding this comment.
Hardcoded index 0 passed to custom strategy function.
getComparisonValue calls strategy(point, 0) with a hardcoded index. If a user's custom function relies on the index parameter to compute a comparison value, this would produce incorrect results. Consider either:
- Accepting an index parameter in
getComparisonValue, or - Documenting that the index is unavailable in this context.
In practice this is low-risk since comparison values are typically derived from point data, not array position.
🤖 Prompt for AI Agents
In `@src/animation/pointMatching.ts` around lines 155 - 163, The function
getComparisonValue currently calls a custom PointMatchingStrategy with a
hardcoded index (strategy(point, 0)), which can break user-provided strategies
that depend on the true point index; update getComparisonValue to accept an
index parameter (e.g., add idx: number) and pass that index into the strategy
call, updating all callers of getComparisonValue accordingly (referencing
getComparisonValue, PointMatchingStrategy, and AnimatablePoint), or
alternatively document in the type/comment that the index is unavailable—prefer
the index-parameter approach so strategy(point, idx) receives the real array
index.
| describe('with animationMatchBy="x" (sliding window scenarios)', () => { | ||
| describe('sliding window shift left [A,B,C] -> [B,C,D]', () => { | ||
| // Initial data points: A at x=0, B at x=1, C at x=2 | ||
| // After shift: B at x=0, C at x=1, D at x=2 (but with same original x values mapped to new positions) | ||
| const initialData = [ | ||
| { name: 'A', x: 0, value: 100 }, | ||
| { name: 'B', x: 1, value: 200 }, | ||
| { name: 'C', x: 2, value: 300 }, | ||
| ]; | ||
| const shiftedData = [ | ||
| { name: 'B', x: 1, value: 200 }, | ||
| { name: 'C', x: 2, value: 300 }, | ||
| { name: 'D', x: 3, value: 400 }, | ||
| ]; | ||
|
|
||
| const renderTestCase = createSelectorTestCase(({ children }) => { | ||
| const [data, setData] = useState(initialData); | ||
| const shiftData = () => setData(shiftedData); | ||
| return ( | ||
| <div> | ||
| <button type="button" onClick={shiftData}> | ||
| Shift data | ||
| </button> | ||
| <LineChart data={data} width={100} height={100}> | ||
| <Line dataKey="value" animationEasing="linear" animationMatchBy="x" /> | ||
| {children} | ||
| </LineChart> | ||
| </div> | ||
| ); | ||
| }); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, check if the file exists and examine the lines in question
stat test/cartesian/Line.animation.spec.tsx 2>/dev/null && echo "File exists" || echo "File not found"Repository: recharts/recharts
Length of output: 75
🏁 Script executed:
# Check the beginning and imports of the test file to see current setup
head -50 test/cartesian/Line.animation.spec.tsxRepository: recharts/recharts
Length of output: 1602
🏁 Script executed:
# Look for vi.useFakeTimers in the test file
rg "vi\.useFakeTimers|useFakeTimers" test/cartesian/Line.animation.spec.tsx -A 2 -B 2Repository: recharts/recharts
Length of output: 43
🏁 Script executed:
# Check for beforeEach and afterEach setup in the file
rg "beforeEach|afterEach" test/cartesian/Line.animation.spec.tsx -A 5 -B 1Repository: recharts/recharts
Length of output: 682
🏁 Script executed:
# Check if there's a vitest configuration file that might set up fake timers globally
find . -maxdepth 2 -name "vitest.config.*" -o -name "vite.config.*" | xargs ls -la 2>/dev/nullRepository: recharts/recharts
Length of output: 199
🏁 Script executed:
# Look at the area around line 1631-1660 to verify the code snippet matches
sed -n '1625,1665p' test/cartesian/Line.animation.spec.tsxRepository: recharts/recharts
Length of output: 1663
Enable fake timers for animation tests using vi.useFakeTimers() in the beforeEach hook and vi.useRealTimers() in afterEach, as required by coding guidelines for all test files due to Redux autoBatchEnhancer dependency on timers and requestAnimationFrame. These animation tests rely on deterministic timing to verify animation behavior.
Setup fake timers
-import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
+import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest';
@@
beforeEach(() => {
+ vi.useFakeTimers();
mockSequenceOfGetBoundingClientRect([
{ width: 0, height: 0, left: 0, top: 50 },
{ width: 50, height: 50, left: 0, top: 50 },
]);
});
+
+afterEach(() => {
+ vi.useRealTimers();
+});🤖 Prompt for AI Agents
In `@test/cartesian/Line.animation.spec.tsx` around lines 1631 - 1660, Add fake
timers for the animation tests by inserting a beforeEach that calls
vi.useFakeTimers() and an afterEach that calls vi.useRealTimers() within the
same describe block that contains renderTestCase/shiftData (the block using
initialData, shiftedData, LineChart and Line with animationMatchBy="x"); this
ensures requestAnimationFrame-based animations are deterministic during tests
and you can then advance timers (e.g., vi.advanceTimersByTime) when clicking the
Shift data button to assert animation states.
|
That's a lot of change for something that can be much simpler. First, why can't we match by index? What would the animation fix look like with the current API and without new props? |
3595103 to
4fd97a8
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@src/animation/pointMatching.ts`:
- Around line 92-104: In the pointMatching.ts logic for the 'index' strategy,
avoid dividing by zero by adding an explicit guard: if currentPoints.length ===
0 return [] before computing prevPointsDiffFactor; this prevents
previousPoints.length / currentPoints.length from producing Infinity and makes
the function contract explicit for the MatchedPoint<T> mapping that follows
(references: strategy === 'index', prevPointsDiffFactor, currentPoints.map).
🧹 Nitpick comments (10)
src/cartesian/GraphicalItemClipPath.tsx (1)
16-16: Consider adding a JSDoc comment for theforceClipprop.The inline comment on line 41 explains the intent well, but a brief JSDoc on the prop itself would help discoverability — especially since the coding guidelines note that JSDoc is used for auto-generated documentation. That said, this is an internal component, so it's a nice-to-have.
src/cartesian/ReferenceLine.tsx (3)
296-300: Duplicatelerphelper — also defined inReferenceArea.tsx.Both
ReferenceLine.tsxandReferenceArea.tsxdefine their ownlerpfunction with slightly different signatures (number | null | undefinedhere vsnumber | undefinedin ReferenceArea). Consider extracting this into a shared utility (e.g., alongside the animation helpers insrc/animation/) to reduce duplication and keep the signatures consistent.
383-389: Ref mutation during render — works but worth noting.
previousEndPointsRef.currentis read and written during the render phase (lines 384 and 389). While this is a common pattern for "track previous value" and works correctly today, React's documentation advises against side effects during render. If strict mode double-rendering or future concurrent features cause issues, consider moving the ref update into auseEffect. For now this is fine in practice.
392-398: Nullish coalescing fallbacks are redundant withresolveDefaultProps.
animationBegin,animationDuration, andanimationEasingare destructured fromPropsWithDefaults, which already guarantees values viareferenceLineDefaultProps. The?? 0/?? 300/?? 'ease'fallbacks are dead code.Not harmful, but they obscure the fact that defaults are handled upstream.
Proposed cleanup
<JavascriptAnimate animationId={`${animationId}-${JSON.stringify(currentEndPoints)}`} - begin={animationBegin ?? 0} - duration={animationDuration ?? 300} + begin={animationBegin} + duration={animationDuration} isActive - easing={animationEasing ?? 'ease'} + easing={animationEasing} >src/cartesian/ReferenceArea.tsx (2)
207-209: Duplicatelerp— same function exists inReferenceLine.tsx.This is the same helper noted in
ReferenceLine.tsx(with a slightly narrower type:number | undefinedvsnumber | null | undefined). Worth extracting to a shared location to keep the signatures consistent and avoid duplication.
279-297: Redundant nullish coalescing and ref mutation during render — same patterns as ReferenceLine.Same observations as in
ReferenceLine.tsx:
animationBegin ?? 0,animationDuration ?? 300,animationEasing ?? 'ease'are redundant sinceresolveDefaultPropsalready guarantees values.previousRectRef.currentis written during render (lines 272, 277), which works in practice but is technically a render-phase side effect.Both are minor nits, not blockers.
src/animation/pointMatching.ts (3)
267-273:fromparameter typed asunknownis unnecessarily broad.Every call site passes
number | null(e.g.,previous.x,previous.y,removed.x). Narrowing the signature to(from: number | null, to: number | null, t: number) => number | nullwould catch misuse at compile time and align with the coding guideline to type parameters explicitly.Proposed fix
export function interpolateMatchedPoints<T extends AnimatablePoint>( matchedPoints: ReadonlyArray<MatchedPoint<T>>, removedPoints: ReadonlyArray<T>, t: number, - interpolateFn: (from: unknown, to: number | null, t: number) => number | null, + interpolateFn: (from: number | null, to: number | null, t: number) => number | null, animateNewValues: boolean, ): T[] {As per coding guidelines, type function parameters and return values explicitly and prefer
unknownonly when truly needed.
249-257: Magic defaultavgSpacing = 100when only one point exists.When there's a single point in the current data,
avgSpacingdefaults to100. This arbitrary value directly controls how far off-screen new/removed points start their entry animation. A very different chart width could produce jarring jumps or barely-visible motion. Consider deriving a fallback from the actual chart dimensions or documenting why100is acceptable.
313-327: Removed points stay in place whenavgShiftis zero.If existing points only change their y-coordinates (same x positions),
avgShiftwill be0, and removed points interpolatexfromremoved.xtoremoved.x - 0, i.e., they never move horizontally. They'll visually persist at their position untilt = 1when they're excluded from the output. This is likely fine for the common sliding-window use case but worth documenting.src/cartesian/Line.tsx (1)
883-884: DuplicatedneedClipderivation logic betweenLineImplandAreaImpl.The expression
needClipFromAxes || (animationClipOverflow && animationMatchBy !== 'index')is repeated identically in bothLine.tsx(Line 884) andArea.tsx(Line 941). Consider extracting this into a shared helper or hook to keep the logic DRY and reduce the risk of divergence.
| // For 'index' strategy, use the legacy stretching algorithm for backward compatibility. | ||
| if (strategy === 'index') { | ||
| const prevPointsDiffFactor = previousPoints.length / currentPoints.length; | ||
| return currentPoints.map((current, currentIndex): MatchedPoint<T> => { | ||
| const prevPointIndex = Math.floor(currentIndex * prevPointsDiffFactor); | ||
| const previous = previousPoints[prevPointIndex] ?? null; | ||
| return { | ||
| current, | ||
| currentIndex, | ||
| previous, | ||
| }; | ||
| }); | ||
| } |
There was a problem hiding this comment.
Potential division issue when currentPoints is empty.
At Line 94, previousPoints.length / currentPoints.length produces Infinity when currentPoints is empty. The subsequent .map() on an empty array means no harm occurs, but the intent is unclear. A guard if (currentPoints.length === 0) return []; before this block would make the contract explicit.
🤖 Prompt for AI Agents
In `@src/animation/pointMatching.ts` around lines 92 - 104, In the
pointMatching.ts logic for the 'index' strategy, avoid dividing by zero by
adding an explicit guard: if currentPoints.length === 0 return [] before
computing prevPointsDiffFactor; this prevents previousPoints.length /
currentPoints.length from producing Infinity and makes the function contract
explicit for the MatchedPoint<T> mapping that follows (references: strategy ===
'index', prevPointsDiffFactor, currentPoints.map).
Index matching maps by array position... so if data shifts from [A, B, C] to [B, C, D], index 0 maps A→B, index 1 maps B→C, etc and every point animates to the wrong place (which is the dancing in #1961) So i think ithout a new prop the only option would be changing the default to match by x-coordinate instead of index, but I think that would break categorical charts where x positions come from array order not data values. I could be wrong here though, might be a simpler way to handle it that I'm not seeing. |
|
Let's see the storybook. I'm still hoping we can find some simpler solution. Why not disable animations? |
Screen.Recording.2026-02-07.at.20.25.50.movAh okay it's nicely visible in the storybook. |
|
Right and if I disable animation I get the same effect but, well, without the animation: Screen.Recording.2026-02-07.at.20.27.33.movRight I see the gap |
4fd97a8 to
80cdb56
Compare
|
This approach (matchBy='x') only works if you are changing the XAxis domain on every tick. If you want to keep the domain the same, you are back where you started: Screen.Recording.2026-02-07.at.20.36.49.mov |
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
test/component/Legend.itemSorter.spec.tsx (1)
58-142:⚠️ Potential issue | 🟡 MinorUse
expectLastCalledWithfor Legend content assertions.These updated assertions still call
toHaveBeenLastCalledWith; please switch to the helper for the preferred typed API.♻️ Suggested update
- expect(customContent).toHaveBeenLastCalledWith( + expectLastCalledWith( + customContent, { /* ... */ }, {}, );- expect(customContent).toHaveBeenLastCalledWith( + expectLastCalledWith( + customContent, { /* ... */ }, {}, );As per coding guidelines, Use the
expectLastCalledWithhelper function instead ofexpect(spy).toHaveBeenLastCalledWith(...)for better typing and autocompletion.Also applies to: 156-241
test/cartesian/Area.spec.tsx (1)
781-812:⚠️ Potential issue | 🟡 MinorUse
expectLastCalledWithfor Legend payload assertions.These modified assertions should use the helper for consistent typed calls.
♻️ Suggested update
- expect(legendSpy).toHaveBeenLastCalledWith([ + expectLastCalledWith(legendSpy, [ { /* ... */ }, ]);- expect(legendSpy).toHaveBeenLastCalledWith([ + expectLastCalledWith(legendSpy, [ { /* ... */ }, ]);As per coding guidelines, Use the
expectLastCalledWithhelper function instead ofexpect(spy).toHaveBeenLastCalledWith(...)for better typing and autocompletion.Also applies to: 841-872
🤖 Fix all issues with AI agents
In `@src/animation/pointMatching.ts`:
- Around line 289-302: The code computes spacingUnits by dividing by avgSpacing
which can be 0 (from findMatchedEndpoints), producing Infinity/NaN; fix by
guarding avgSpacing === 0: when avgSpacing is 0 do not divide — for
leftmost/rightmost branches compute entryX using the previous.x plus/minus
avgShift (e.g., entryX = (leftmost.previous.x ?? current.x) - avgShift for left,
and entryX = (rightmost.previous.x ?? current.x) + avgShift for right) or
otherwise set spacingUnits = 0 so multiplication yields no NaN; update the logic
around animateNewValues/current.x/leftmost/rightmost/avgSpacing/entryX so
interpolateFn never receives NaN.
In `@src/cartesian/Line.tsx`:
- Around line 697-706: The current assignment to previousPointsRef.current uses
stepData (from interpolateMatchedPoints) for t < 1, which can include appended
removed/phantom points; change the logic so that if t < 1 and
removedPoints.length > 0 you store the clean target points array (points)
instead of stepData to avoid carrying intermediate/removed points across frames,
otherwise you may keep stepData for smooth interpolation; retain the existing
behavior of setting previousPointsRef.current = points when t === 1. Use the
variables/methods matchPointsWithRemovals, interpolateMatchedPoints,
matchedPoints/removedPoints, stepData, t, animateNewValues and previousPointsRef
to locate and update this conditional assignment.
- Line 26: The public API is missing the PointMatchingStrategy type export (and
optionally the helper functions) required by consumers using the
animationMatchBy prop; add an export line to the package entry that re-exports
PointMatchingStrategy from animation/pointMatching and optionally re-export
matchPointsWithRemovals and interpolateMatchedPoints so users can import those
helpers when implementing custom matching logic (i.e. add exports for
PointMatchingStrategy, and if desired matchPointsWithRemovals and
interpolateMatchedPoints).
In `@src/cartesian/ReferenceLine.tsx`:
- Around line 383-409: The jump happens because previousEndPointsRef.current is
overwritten with currentEndPoints immediately when a new update arrives, so an
interrupted animation's visible position is lost; fix by tracking the last
rendered/interpolated position and using that as the start of the next animation
instead of the original target. Concretely: add a lastAnimatedRef (or similar)
and update it inside the JavascriptAnimate render callback (where you compute
animated via lerp) to the current interpolated endpoints, use
lastAnimatedRef.current (falling back to previousEndPointsRef.current) as the
prev value when starting a new animation, and only set
previousEndPointsRef.current to currentEndPoints once the animation completes
(or set it from lastAnimatedRef on interruption); references:
previousEndPointsRef, currentEndPoints, JavascriptAnimate, animationId,
renderContent, lerp.
🧹 Nitpick comments (5)
src/cartesian/Line.tsx (1)
883-884: Clipping is always forced for non-index strategies, even when animation is off.
needClipistruewheneveranimationClipOverflow && animationMatchBy !== 'index', regardless ofisAnimationActive. This means a permanent clip path is applied even if the user has disabled animation. Consider gating onisAnimationActiveas well:- const needClip = needClipFromAxes || (animationClipOverflow && animationMatchBy !== 'index'); + const needClip = needClipFromAxes || (animationClipOverflow && animationMatchBy !== 'index' && isAnimationActive !== false);src/cartesian/ReferenceLine.tsx (2)
296-300:lerpsilently coercesnull/undefinedto0.The
?? 0fallback means if either endpoint coordinate isnullorundefined, interpolation silently treats it as0. For reference lines this is unlikely to be a problem since endpoints are calculated from scales, but it hides potential upstream bugs. Consider logging or returningNaN/nullfor invalid inputs.
393-398:animationBegin ?? 0andanimationDuration ?? 300are redundant.These props already have defaults via
resolveDefaultPropsat line 446, so they can never beundefinedhere. The??fallbacks are harmless but misleading.- begin={animationBegin ?? 0} - duration={animationDuration ?? 300} + begin={animationBegin} + duration={animationDuration} isActive - easing={animationEasing ?? 'ease'} + easing={animationEasing}src/animation/pointMatching.ts (2)
10-15:'x'inPointMatchingStrategyis indistinguishable from a DataKey at runtime.The
'x'literal is listed as a special strategy in docs ("Use 'x' for time-series sliding windows"), butgetPointKeyhas no special branch for it — it falls through to the DataKey path looking uppayload.x. This works if the user's data objects have anxfield, but may silently fail to match if they don't (e.g.,{date: '...', value: 10}).Consider either:
- Adding a dedicated
'x'branch ingetPointKeythat usespoint.x(the pixel coordinate) directly, or- Clarifying in the JSDoc that
'x'means "usexas a DataKey on the payload", not "match by x-coordinate."
224-261:avgSpacingdefaults to100— magic number.When there is only one point,
avgSpacingfalls back to100(Line 256). This arbitrary pixel value may produce unexpected entry animation distances depending on chart dimensions. Consider deriving a fallback from the chart width or documenting why100was chosen.
| if (animateNewValues && current.x !== null) { | ||
| let entryX: number; | ||
|
|
||
| if (leftmost && current.x < (leftmost.current.x ?? Infinity)) { | ||
| // New point is to the LEFT of all matched points | ||
| const spacingUnits = ((leftmost.current.x ?? 0) - current.x) / avgSpacing; | ||
| entryX = (leftmost.previous.x ?? 0) - spacingUnits * avgSpacing; | ||
| } else if (rightmost && current.x > (rightmost.current.x ?? -Infinity)) { | ||
| // New point is to the RIGHT of all matched points | ||
| const spacingUnits = (current.x - (rightmost.current.x ?? 0)) / avgSpacing; | ||
| entryX = (rightmost.previous.x ?? 0) + spacingUnits * avgSpacing; | ||
| } else { | ||
| entryX = current.x + avgShift; | ||
| } |
There was a problem hiding this comment.
Division by zero when avgSpacing is 0 produces NaN entry positions.
When all current points share the same x-coordinate, avgSpacing from findMatchedEndpoints is 0. Then spacingUnits = distance / 0 = Infinity, and entryX = prev.x - Infinity * 0 = NaN. The downstream interpolateFn(NaN, ...) likely falls back to current.x (since isNumber(NaN) is false in most implementations), but this is fragile and relies on an implicit safety net.
Suggested guard
if (animateNewValues && current.x !== null) {
let entryX: number;
- if (leftmost && current.x < (leftmost.current.x ?? Infinity)) {
+ if (avgSpacing === 0) {
+ // All points at same x; no meaningful extrapolation possible
+ entryX = current.x;
+ } else if (leftmost && current.x < (leftmost.current.x ?? Infinity)) {
const spacingUnits = ((leftmost.current.x ?? 0) - current.x) / avgSpacing;
entryX = (leftmost.previous.x ?? 0) - spacingUnits * avgSpacing;
} else if (rightmost && current.x > (rightmost.current.x ?? -Infinity)) {🤖 Prompt for AI Agents
In `@src/animation/pointMatching.ts` around lines 289 - 302, The code computes
spacingUnits by dividing by avgSpacing which can be 0 (from
findMatchedEndpoints), producing Infinity/NaN; fix by guarding avgSpacing === 0:
when avgSpacing is 0 do not divide — for leftmost/rightmost branches compute
entryX using the previous.x plus/minus avgShift (e.g., entryX =
(leftmost.previous.x ?? current.x) - avgShift for left, and entryX =
(rightmost.previous.x ?? current.x) + avgShift for right) or otherwise set
spacingUnits = 0 so multiplication yields no NaN; update the logic around
animateNewValues/current.x/leftmost/rightmost/avgSpacing/entryX so interpolateFn
never receives NaN.
| import { Dots } from '../component/Dots'; | ||
| import { ErrorBarDataItem, ErrorBarDataPointFormatter } from './ErrorBar'; | ||
| import { interpolate, isNullish, noop } from '../util/DataUtils'; | ||
| import { PointMatchingStrategy, matchPointsWithRemovals, interpolateMatchedPoints } from '../animation/pointMatching'; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Check if PointMatchingStrategy is exported from the public API entry point
rg -n 'PointMatchingStrategy' src/index.tsRepository: recharts/recharts
Length of output: 43
🏁 Script executed:
# Find where PointMatchingStrategy is defined
rg -n 'type PointMatchingStrategy\|interface PointMatchingStrategy' src/Repository: recharts/recharts
Length of output: 43
🏁 Script executed:
# Check how animationMatchBy is defined and what type it expects
rg -n 'animationMatchBy' src/ -A 3 -B 1 | head -50Repository: recharts/recharts
Length of output: 2530
🏁 Script executed:
# Search more broadly for PointMatchingStrategy definition
rg 'PointMatchingStrategy' src/ --type ts --type tsx | head -20Repository: recharts/recharts
Length of output: 88
🏁 Script executed:
# Check what's exported from the animation module
cat src/animation/pointMatching.ts | head -100Repository: recharts/recharts
Length of output: 2895
🏁 Script executed:
# Check what's exported from src/index.ts, especially around Line
rg 'export.*Line' src/index.tsRepository: recharts/recharts
Length of output: 367
🏁 Script executed:
# Check if animation utilities are exported from public API
rg 'animation|pointMatching' src/index.tsRepository: recharts/recharts
Length of output: 43
🏁 Script executed:
# Look at the full structure of src/index.ts to understand exports
wc -l src/index.tsRepository: recharts/recharts
Length of output: 77
🏁 Script executed:
# Read the full src/index.ts to understand the export pattern
cat -n src/index.tsRepository: recharts/recharts
Length of output: 10496
🏁 Script executed:
# Check what the LineProps interface contains and if it re-exports PointMatchingStrategy
rg -A 30 'export type.*Props as LineProps' src/cartesian/Line.tsx | head -50Repository: recharts/recharts
Length of output: 43
🏁 Script executed:
# Check if PointMatchingStrategy is used in other components besides Line
rg 'PointMatchingStrategy' src/cartesian/ --type tsRepository: recharts/recharts
Length of output: 576
🏁 Script executed:
# Check what types are in the Props interface that uses PointMatchingStrategy
sed -n '160,180p' src/cartesian/Line.tsxRepository: recharts/recharts
Length of output: 795
🏁 Script executed:
# Check if users importing LineProps would have access to the types they reference
rg 'interface Props|type Props' src/cartesian/Line.tsx -A 50 | head -80Repository: recharts/recharts
Length of output: 1258
Export PointMatchingStrategy from the public API entry point.
End users need access to this type when using the animationMatchBy prop with custom functions in Line and Area components. Add to src/index.ts:
export type { PointMatchingStrategy } from './animation/pointMatching';Consider also exporting utility functions matchPointsWithRemovals and interpolateMatchedPoints if users are expected to implement custom animation matching logic.
🤖 Prompt for AI Agents
In `@src/cartesian/Line.tsx` at line 26, The public API is missing the
PointMatchingStrategy type export (and optionally the helper functions) required
by consumers using the animationMatchBy prop; add an export line to the package
entry that re-exports PointMatchingStrategy from animation/pointMatching and
optionally re-export matchPointsWithRemovals and interpolateMatchedPoints so
users can import those helpers when implementing custom matching logic (i.e. add
exports for PointMatchingStrategy, and if desired matchPointsWithRemovals and
interpolateMatchedPoints).
| const { matched: matchedPoints, removed: removedPoints } = matchPointsWithRemovals( | ||
| points, | ||
| prevPoints, | ||
| animationMatchBy, | ||
| ); | ||
|
|
||
| const stepData = interpolateMatchedPoints(matchedPoints, removedPoints, t, interpolate, animateNewValues); | ||
|
|
||
| // eslint-disable-next-line no-param-reassign | ||
| previousPointsRef.current = stepData; | ||
| previousPointsRef.current = t === 1 ? points : stepData; |
There was a problem hiding this comment.
During interrupted animations, previousPointsRef holds intermediate/removed points.
When t < 1, previousPointsRef.current is set to stepData (Line 706), which includes interpolated positions and appended removed points. If a data update arrives mid-animation, the next render picks up this intermediate state as prevPoints, so the matching algorithm will try to match against ghost removed points with in-between coordinates.
This matches the pre-existing pattern (Line 674 also updates the ref mid-animation), but with identity-based matching it's riskier because removed phantom points could produce spurious matches by key. Consider guarding so that on interruption, only the clean points array is stored, not stepData.
Suggested approach
- previousPointsRef.current = t === 1 ? points : stepData;
+ // Always store the target points so an interrupted animation
+ // doesn't leave phantom removed-points in the ref.
+ previousPointsRef.current = points;This way, if the animation is interrupted, the next cycle starts from the actual target data rather than an intermediate frame.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const { matched: matchedPoints, removed: removedPoints } = matchPointsWithRemovals( | |
| points, | |
| prevPoints, | |
| animationMatchBy, | |
| ); | |
| const stepData = interpolateMatchedPoints(matchedPoints, removedPoints, t, interpolate, animateNewValues); | |
| // eslint-disable-next-line no-param-reassign | |
| previousPointsRef.current = stepData; | |
| previousPointsRef.current = t === 1 ? points : stepData; | |
| const { matched: matchedPoints, removed: removedPoints } = matchPointsWithRemovals( | |
| points, | |
| prevPoints, | |
| animationMatchBy, | |
| ); | |
| const stepData = interpolateMatchedPoints(matchedPoints, removedPoints, t, interpolate, animateNewValues); | |
| // eslint-disable-next-line no-param-reassign | |
| // Always store the target points so an interrupted animation | |
| // doesn't leave phantom removed-points in the ref. | |
| previousPointsRef.current = points; |
🤖 Prompt for AI Agents
In `@src/cartesian/Line.tsx` around lines 697 - 706, The current assignment to
previousPointsRef.current uses stepData (from interpolateMatchedPoints) for t <
1, which can include appended removed/phantom points; change the logic so that
if t < 1 and removedPoints.length > 0 you store the clean target points array
(points) instead of stepData to avoid carrying intermediate/removed points
across frames, otherwise you may keep stepData for smooth interpolation; retain
the existing behavior of setting previousPointsRef.current = points when t ===
1. Use the variables/methods matchPointsWithRemovals, interpolateMatchedPoints,
matchedPoints/removedPoints, stepData, t, animateNewValues and previousPointsRef
to locate and update this conditional assignment.
| if (!isAnimationActive || !previousEndPointsRef.current) { | ||
| previousEndPointsRef.current = currentEndPoints; | ||
| return renderContent(currentEndPoints); | ||
| } | ||
|
|
||
| const prev = previousEndPointsRef.current; | ||
| previousEndPointsRef.current = currentEndPoints; | ||
|
|
||
| return ( | ||
| <ZIndexLayer zIndex={props.zIndex}> | ||
| <Layer className={clsx('recharts-reference-line', className)}> | ||
| {renderLine(shape, lineProps)} | ||
| <CartesianLabelContextProvider {...rect} lowerWidth={rect.width} upperWidth={rect.width}> | ||
| <CartesianLabelFromLabelProp label={props.label} /> | ||
| {props.children} | ||
| </CartesianLabelContextProvider> | ||
| </Layer> | ||
| </ZIndexLayer> | ||
| <JavascriptAnimate | ||
| animationId={`${animationId}-${JSON.stringify(currentEndPoints)}`} | ||
| begin={animationBegin ?? 0} | ||
| duration={animationDuration ?? 300} | ||
| isActive | ||
| easing={animationEasing ?? 'ease'} | ||
| > | ||
| {(t: number) => { | ||
| const animated: LineEndPoints = { | ||
| x1: lerp(prev.x1, currentEndPoints.x1, t), | ||
| y1: lerp(prev.y1, currentEndPoints.y1, t), | ||
| x2: lerp(prev.x2, currentEndPoints.x2, t), | ||
| y2: lerp(prev.y2, currentEndPoints.y2, t), | ||
| }; | ||
| return renderContent(animated); | ||
| }} | ||
| </JavascriptAnimate> | ||
| ); |
There was a problem hiding this comment.
Animation interruption causes a visual jump.
When a new data update arrives mid-animation, previousEndPointsRef.current is already set to the target endpoints (Line 389), not the current visual position. So the next animation starts from the target of the interrupted animation rather than the actual interpolated position, causing a visible jump.
To fix, capture the interpolated position at the moment of interruption:
Sketch of a smoother approach
+ const lastRenderedRef = useRef<LineEndPoints | null>(null);
...
if (!isAnimationActive || !previousEndPointsRef.current) {
previousEndPointsRef.current = currentEndPoints;
+ lastRenderedRef.current = currentEndPoints;
return renderContent(currentEndPoints);
}
- const prev = previousEndPointsRef.current;
+ const prev = lastRenderedRef.current ?? previousEndPointsRef.current;
previousEndPointsRef.current = currentEndPoints;
return (
<JavascriptAnimate ...>
{(t: number) => {
const animated: LineEndPoints = { ... };
+ lastRenderedRef.current = animated;
return renderContent(animated);
}}
</JavascriptAnimate>
);🤖 Prompt for AI Agents
In `@src/cartesian/ReferenceLine.tsx` around lines 383 - 409, The jump happens
because previousEndPointsRef.current is overwritten with currentEndPoints
immediately when a new update arrives, so an interrupted animation's visible
position is lost; fix by tracking the last rendered/interpolated position and
using that as the start of the next animation instead of the original target.
Concretely: add a lastAnimatedRef (or similar) and update it inside the
JavascriptAnimate render callback (where you compute animated via lerp) to the
current interpolated endpoints, use lastAnimatedRef.current (falling back to
previousEndPointsRef.current) as the prev value when starting a new animation,
and only set previousEndPointsRef.current to currentEndPoints once the animation
completes (or set it from lastAnimatedRef on interruption); references:
previousEndPointsRef, currentEndPoints, JavascriptAnimate, animationId,
renderContent, lerp.
|
Now hear me out @robjampar for a moment. The animation has a ton of orchestration around it but eventually it boils down to this part here https://github.com/recharts/recharts/blob/main/src/cartesian/Line.tsx#L683 where it calculates What if we exposed this part as a prop? What do you think? |
|
Also, to keep things focused:
This should be the default, let's treat it as a bugfix, merge in separate PR, doesn't need to be a prop
Separate PR please |
|
Ok good idea - reworked this based on your suggestions: replaced animationMatchBy with animationStepFunction — the step data computation is now exposed as a prop like you described. the default code path (no prop) is completely unchanged from main, and recharts exports two presets:
also split out the other bits:
|
80cdb56 to
6b75096
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@src/animation/pointMatching.ts`:
- Around line 314-328: The removed points currently interpolate toward removed.x
- avgShift and freeze when matchedCount is 0 because avgShift becomes 0; change
the logic that computes the target shift used in removedInterpolated so if
matchedCount === 0 you use a non-zero fallbackShift (e.g., a small constant or a
direction-based offset computed from removed.x relative to the chart
center/median) and apply that fallback instead of 0 when calling interpolateFn
inside removedInterpolated so removed points visibly exit even on complete
replacements.
In `@src/cartesian/Area.tsx`:
- Around line 685-704: The code uses an unsafe "as ReadonlyArray<AreaPointItem>"
cast on prevBaseLine before calling animationStepFunction; remove the assertion
and add a runtime type-narrowing guard (e.g.,
isAreaPointItemArray(prevBaseLine)) to confirm each element has the required
AreaPointItem fields before calling animationStepFunction(baseLine,
prevBaseLine, t), or alternatively change the stored type of previousBaselineRef
to the more precise ReadonlyArray<AreaPointItem> if that is always true; ensure
the branch uses the guarded type so no "as" cast is needed and adjust
AnimationStepFunction/previousBaselineRef types if necessary.
🧹 Nitpick comments (3)
omnidoc/verifyExamples.spec.ts (1)
182-184: New exports correctly registered as needing examples.These are added to the
exportsThatNeedExampleslist following the established pattern. Since a Storybook story (AnimationMatchBy.stories.tsx) already demonstratesxMatchLinearInterpolation, you may be able to remove it from this list if theExampleReaderpicks up storybook stories as examples.test/animation/pointMatching.spec.ts (1)
755-804: Consider adding tests foranimateNewValues=falseand new-point entry extrapolation.
interpolateMatchedPointshas branching logic foranimateNewValues(true vs false) and entry position extrapolation (left of leftmost, right of rightmost, between matched). The current tests cover the happy path but don't exercise:
animateNewValues=false(new points should be returned as-is without entry animation)- Entry position extrapolation for new points appearing to the left/right of matched endpoints
- The
avgShiftfallback path (new point between matched endpoints)These paths are critical for the animation correctness that this PR is solving.
As per coding guidelines: "Aim for 100% unit test code coverage when writing new code"
src/cartesian/Line.tsx (1)
887-888: Unnecessary intermediate variable from removed feature.
needClipFromAxesis immediately assigned toneedClipwithout any transformation. This appears to be a leftover from the removedanimationClipOverflowfeature. Simplify:Suggested fix
- const { needClip: needClipFromAxes } = useNeedsClip(xAxisId, yAxisId); - const needClip = needClipFromAxes; + const { needClip } = useNeedsClip(xAxisId, yAxisId);
| // Removed points slide out in the direction of the overall shift | ||
| const removedInterpolated: T[] = removedPoints.map((removed): T => { | ||
| if (removed.x !== null) { | ||
| return { | ||
| ...removed, | ||
| x: interpolateFn(removed.x, removed.x - avgShift, t), | ||
| y: removed.y, | ||
| }; | ||
| } | ||
| return removed; | ||
| }); | ||
|
|
||
| const result = [...interpolated, ...removedInterpolated]; | ||
| result.sort((a, b) => (a.x ?? 0) - (b.x ?? 0)); | ||
| return result; |
There was a problem hiding this comment.
Removed points animate toward removed.x - avgShift, but when avgShift is 0 they freeze in place.
When data is completely replaced (no overlap between previous and current), matchedCount is 0, so avgShift is 0. Removed points then interpolate from removed.x to removed.x - 0 = removed.x — they don't visually exit; they just stay and then vanish at t=1. This may or may not be the intended behavior for a complete data replacement scenario.
🤖 Prompt for AI Agents
In `@src/animation/pointMatching.ts` around lines 314 - 328, The removed points
currently interpolate toward removed.x - avgShift and freeze when matchedCount
is 0 because avgShift becomes 0; change the logic that computes the target shift
used in removedInterpolated so if matchedCount === 0 you use a non-zero
fallbackShift (e.g., a small constant or a direction-based offset computed from
removed.x relative to the chart center/median) and apply that fallback instead
of 0 when calling interpolateFn inside removedInterpolated so removed points
visibly exit even on complete replacements.
6b75096 to
dda770e
Compare
Co-Authored-By: Claude Opus 4.6 <[email protected]>
dda770e to
a0d30f3
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@src/cartesian/Area.tsx`:
- Around line 695-706: The interpolate(...) calls can return null but
stepBaseLine is typed BaseLineType (no null); update the two places calling
interpolate (in the isNumber and isNullish/isNan branches) to coerce a null
result to an acceptable BaseLineType value (e.g., use the numeric fallback 0
when interpolate returns null) or otherwise convert the nullable result before
assigning to stepBaseLine; ensure this change references the interpolate
function, the stepBaseLine variable, and preserves existing branches (isNumber,
isNullish, isNan) so the assignment always produces a value conforming to
BaseLineType.
In `@test/cartesian/Line.animation.spec.tsx`:
- Around line 1631-1660: The test currently uses a categorical x-axis so
xMatchLinearInterpolation isn't exercised; update the renderTestCase to include
a numeric XAxis (e.g., add an <XAxis dataKey="x" type="number"
domain={['dataMin','dataMax']} /> inside the LineChart alongside the <Line ...
animationStepFunction={xMatchLinearInterpolation} /> so pixel positions are
derived from the numeric x value and the sliding-window shift-left, append, and
prepend scenarios truly match points by x rather than by array index.
🧹 Nitpick comments (3)
src/animation/pointMatching.ts (2)
24-49:getPointKeywithstrategy = 'x'silently falls through to the DataKey code path.When the strategy is the string literal
'x', the function doesn't hit the'index'guard (Line 29) or thetypeof === 'function'guard (Line 33), so it proceeds togetValueByDataKey(point.payload, 'x'). This is intentional but not obvious — a brief comment at the// strategy is a DataKeyline (37) noting that'x'is included here by design would help maintainability.
250-262: Magic number100foravgSpacingfallback when there's a single point.When all current points share the same x or there's only one point,
avgSpacingdefaults to100(Line 257). This arbitrary constant affects entry/exit animation positions for new and removed points. Consider extracting it as a named constant with a brief comment explaining the rationale.Suggested change
+const DEFAULT_SPACING_FALLBACK = 100; + function findMatchedEndpoints<T extends AnimatablePoint>( matchedPoints: ReadonlyArray<MatchedPoint<T>>, ): MatchedEndpoints<T> { ... - const avgSpacing = allCurrentX.length > 1 ? (lastX - firstX) / (allCurrentX.length - 1) : 100; + const avgSpacing = allCurrentX.length > 1 ? (lastX - firstX) / (allCurrentX.length - 1) : DEFAULT_SPACING_FALLBACK;src/cartesian/Area.tsx (1)
916-984:animationStepFunctionflows througheverythingElsespread — correct but implicit.
AreaImpldestructures many props fromresolveDefaultPropsbutanimationStepFunctionis not listed, so it lands in...everythingElseand is spread onto<AreaWithState>. This works becauseInternalAreaPropsdeclares the prop. However, it's less discoverable than the explicitly listed props.
| if (isNumber(baseLine)) { | ||
| stepBaseLine = interpolate(prevBaseLine, baseLine, t); | ||
| } else if (isNullish(baseLine) || isNan(baseLine)) { | ||
| stepBaseLine = interpolate(prevBaseLine, 0, t); | ||
| } else if (Array.isArray(prevBaseLine)) { | ||
| // baseLine and prevBaseLine are both coordinate arrays here; use a generic step function | ||
| // to avoid an unsafe type assertion between NullableCoordinate and AreaPointItem | ||
| const baselineStepFn: AnimationStepFunction<AnimatablePoint> = animationStepFunction; | ||
| stepBaseLine = t === 1 ? baseLine : baselineStepFn(baseLine, prevBaseLine, t); | ||
| } else { | ||
| stepBaseLine = baseLine; | ||
| } |
There was a problem hiding this comment.
interpolate returns number | null but stepBaseLine is typed BaseLineType (which excludes null).
On Lines 696 and 698, interpolate(...) returns number | null, but stepBaseLine is declared as BaseLineType (number | ReadonlyArray<NullableCoordinate>). If interpolate returns null, the type contract is violated. This is the same pattern as the existing default branch (Lines 731-733), so it's a pre-existing issue, but worth noting since it's replicated in the new code path.
🤖 Prompt for AI Agents
In `@src/cartesian/Area.tsx` around lines 695 - 706, The interpolate(...) calls
can return null but stepBaseLine is typed BaseLineType (no null); update the two
places calling interpolate (in the isNumber and isNullish/isNan branches) to
coerce a null result to an acceptable BaseLineType value (e.g., use the numeric
fallback 0 when interpolate returns null) or otherwise convert the nullable
result before assigning to stepBaseLine; ensure this change references the
interpolate function, the stepBaseLine variable, and preserves existing branches
(isNumber, isNullish, isNan) so the assignment always produces a value
conforming to BaseLineType.
| describe('with animationStepFunction={xMatchLinearInterpolation} (sliding window scenarios)', () => { | ||
| describe('sliding window shift left [A,B,C] -> [B,C,D]', () => { | ||
| // Initial data points: A at x=0, B at x=1, C at x=2 | ||
| // After shift: B at x=0, C at x=1, D at x=2 (but with same original x values mapped to new positions) | ||
| const initialData = [ | ||
| { name: 'A', x: 0, value: 100 }, | ||
| { name: 'B', x: 1, value: 200 }, | ||
| { name: 'C', x: 2, value: 300 }, | ||
| ]; | ||
| const shiftedData = [ | ||
| { name: 'B', x: 1, value: 200 }, | ||
| { name: 'C', x: 2, value: 300 }, | ||
| { name: 'D', x: 3, value: 400 }, | ||
| ]; | ||
|
|
||
| const renderTestCase = createSelectorTestCase(({ children }) => { | ||
| const [data, setData] = useState(initialData); | ||
| const shiftData = () => setData(shiftedData); | ||
| return ( | ||
| <div> | ||
| <button type="button" onClick={shiftData}> | ||
| Shift data | ||
| </button> | ||
| <LineChart data={data} width={100} height={100}> | ||
| <Line dataKey="value" animationEasing="linear" animationStepFunction={xMatchLinearInterpolation} /> | ||
| {children} | ||
| </LineChart> | ||
| </div> | ||
| ); | ||
| }); |
There was a problem hiding this comment.
Test setup may not exercise xMatchLinearInterpolation as intended — categorical x-axis matches by pixel index, not by data identity.
Without a numeric <XAxis dataKey="x" type="number" domain={...} />, the x-axis defaults to categorical, assigning pixel positions by array index. In that case, xMatchLinearInterpolation matching by pixel x is effectively the same as index-based matching — B at index 0 in the new data gets the same pixel x as A at index 0 in the old data, not B at index 1.
To truly exercise x-based matching (B→B, C→C), add a numeric XAxis so that each data point's pixel x derives from its x data value:
Suggested fix for the shift-left test case
<LineChart data={data} width={100} height={100}>
+ <XAxis dataKey="x" type="number" domain={['dataMin', 'dataMax']} />
<Line dataKey="value" animationEasing="linear" animationStepFunction={xMatchLinearInterpolation} />
{children}
</LineChart>Apply the same pattern to the append and prepend test cases.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| describe('with animationStepFunction={xMatchLinearInterpolation} (sliding window scenarios)', () => { | |
| describe('sliding window shift left [A,B,C] -> [B,C,D]', () => { | |
| // Initial data points: A at x=0, B at x=1, C at x=2 | |
| // After shift: B at x=0, C at x=1, D at x=2 (but with same original x values mapped to new positions) | |
| const initialData = [ | |
| { name: 'A', x: 0, value: 100 }, | |
| { name: 'B', x: 1, value: 200 }, | |
| { name: 'C', x: 2, value: 300 }, | |
| ]; | |
| const shiftedData = [ | |
| { name: 'B', x: 1, value: 200 }, | |
| { name: 'C', x: 2, value: 300 }, | |
| { name: 'D', x: 3, value: 400 }, | |
| ]; | |
| const renderTestCase = createSelectorTestCase(({ children }) => { | |
| const [data, setData] = useState(initialData); | |
| const shiftData = () => setData(shiftedData); | |
| return ( | |
| <div> | |
| <button type="button" onClick={shiftData}> | |
| Shift data | |
| </button> | |
| <LineChart data={data} width={100} height={100}> | |
| <Line dataKey="value" animationEasing="linear" animationStepFunction={xMatchLinearInterpolation} /> | |
| {children} | |
| </LineChart> | |
| </div> | |
| ); | |
| }); | |
| describe('with animationStepFunction={xMatchLinearInterpolation} (sliding window scenarios)', () => { | |
| describe('sliding window shift left [A,B,C] -> [B,C,D]', () => { | |
| // Initial data points: A at x=0, B at x=1, C at x=2 | |
| // After shift: B at x=0, C at x=1, D at x=2 (but with same original x values mapped to new positions) | |
| const initialData = [ | |
| { name: 'A', x: 0, value: 100 }, | |
| { name: 'B', x: 1, value: 200 }, | |
| { name: 'C', x: 2, value: 300 }, | |
| ]; | |
| const shiftedData = [ | |
| { name: 'B', x: 1, value: 200 }, | |
| { name: 'C', x: 2, value: 300 }, | |
| { name: 'D', x: 3, value: 400 }, | |
| ]; | |
| const renderTestCase = createSelectorTestCase(({ children }) => { | |
| const [data, setData] = useState(initialData); | |
| const shiftData = () => setData(shiftedData); | |
| return ( | |
| <div> | |
| <button type="button" onClick={shiftData}> | |
| Shift data | |
| </button> | |
| <LineChart data={data} width={100} height={100}> | |
| <XAxis dataKey="x" type="number" domain={['dataMin', 'dataMax']} /> | |
| <Line dataKey="value" animationEasing="linear" animationStepFunction={xMatchLinearInterpolation} /> | |
| {children} | |
| </LineChart> | |
| </div> | |
| ); | |
| }); |
🤖 Prompt for AI Agents
In `@test/cartesian/Line.animation.spec.tsx` around lines 1631 - 1660, The test
currently uses a categorical x-axis so xMatchLinearInterpolation isn't
exercised; update the renderTestCase to include a numeric XAxis (e.g., add an
<XAxis dataKey="x" type="number" domain={['dataMin','dataMax']} /> inside the
LineChart alongside the <Line ...
animationStepFunction={xMatchLinearInterpolation} /> so pixel positions are
derived from the numeric x value and the sliding-window shift-left, append, and
prepend scenarios truly match points by x rather than by array index.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6973 +/- ##
==========================================
+ Coverage 90.38% 90.40% +0.01%
==========================================
Files 515 516 +1
Lines 38522 38804 +282
Branches 5342 5434 +92
==========================================
+ Hits 34818 35080 +262
- Misses 3695 3715 +20
Partials 9 9 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
Bundle ReportChanges will increase total bundle size by 15.28kB (0.53%) ⬆️. This is within the configured threshold ✅ Detailed changes
Affected Assets, Files, and Routes:view changes for bundle: recharts/bundle-cjsAssets Changed:
|
PavelVanecek
left a comment
There was a problem hiding this comment.
Okay I think it's much better like this. @ckifer wdyt?
Can we please polish this a little bit before merging @robjampar ? Extra docs and examples would be nice too. Please prefer the website instead of storybooks because #6668
| 'useYAxisScale', | ||
| 'useXAxisTicks', | ||
| 'useYAxisTicks', | ||
| 'AnimationStepFunction', |
There was a problem hiding this comment.
Let's add examples on the website, or even better write a whole new guide on how to work with these.
| payload?: unknown; | ||
| } | ||
|
|
||
| /** 'index' preserves legacy behavior; 'x' matches by coordinate; a DataKey or function matches by identity. */ |
There was a problem hiding this comment.
Nothing in this behaviour is "legacy", don't call it that or we spook people into thinking we're removing it.
| } | ||
|
|
||
| /** 'index' preserves legacy behavior; 'x' matches by coordinate; a DataKey or function matches by identity. */ | ||
| export type PointMatchingStrategy<T extends AnimatablePoint = AnimatablePoint> = |
There was a problem hiding this comment.
We can now simplify this a bit since we expose the interpolation function. The strategy structure can go away
| } | ||
|
|
||
| // For 'index' strategy, use the legacy stretching algorithm for backward compatibility. | ||
| if (strategy === 'index') { |
There was a problem hiding this comment.
The "index" strategy is now implemented three times: once here, then in indexMatchLinearInterpolation, and then third time inside of Area. Let's keep indexMatchLinearInterpolation, make that a default prop, and remove the other two.
| * import { xMatchLinearInterpolation } from 'recharts'; | ||
| * <Area animationStepFunction={xMatchLinearInterpolation} /> | ||
| */ | ||
| animationStepFunction?: AnimationStepFunction<AreaPointItem>; |
There was a problem hiding this comment.
This is the first time when we actually expose the AreaPointItem (and LinePointItem below too), isn't it. Let's also export the hooks that select these two, before people start hacking the animation to get access to the points directly, shall we. And add JSDoc to the type definition of these two to explain what is going on.
| * Matches points by x-coordinate so existing points slide smoothly, | ||
| * new points animate in from the edges, and removed points slide off-screen. | ||
| */ | ||
| export function xMatchLinearInterpolation<T extends AnimatablePoint>( |
There was a problem hiding this comment.
Can we use this method to fix #1997 too? If yes can you please add the same prop to Bar as well?
| removedPoints: ReadonlyArray<T>, | ||
| t: number, | ||
| interpolateFn: (from: unknown, to: number | null, t: number) => number | null, | ||
| animateNewValues: boolean, |
There was a problem hiding this comment.
animateNewValues is always true, can we remove the param?
| matchedPoints: ReadonlyArray<MatchedPoint<T>>, | ||
| removedPoints: ReadonlyArray<T>, | ||
| t: number, | ||
| interpolateFn: (from: unknown, to: number | null, t: number) => number | null, |
There was a problem hiding this comment.
Why not hardcode interpolate from DataUtils? It's doing the same thing
|
yeah this is fine to do. Agreed with all of @PavelVanecek 's comments though - this needs cleaned up |
I want to work on #6973 next so first I wanted to have some place where we document animations so I wrote one. While in there I noticed that our public animation type does not allow `spring` and custom curves even though the actual implementation does so I fixed the type too. Also includes some copilot skills because the AI is now quite capable of writing the website examples and it may save some time. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a comprehensive "Animations" guide with interactive example and controls; introduced a new easing input type and widened animation-easing support across chart components to accept more easing formats while preserving existing defaults. * **Documentation** * Added contributor guides for examples, guide pages, and visual-regression (VR) test authoring and workflows; localized and registered the new Animations guide in the site navigation. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
|
I'm going to decline this one @robjampar and merge #7215 instead. I'll still include you as contributor |
# Description I like the props introduced in #6973 but the author stopped responding so I am recreating. With some extra additions on top. This PR includes: - Extracting the shared animation logic into new components - Allow overriding the default animation logic using custom props - Documentation and examples Discussion: #7235 # Related issues Closes #1961 Closes #1997 Closes #287 Closes #7207 Closes #2575 Closes #559 Closes #5547 Closes #5603 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Manual animation control with programmatic progress handles * Per-chart animation options: match-by strategies and custom interpolation hooks * New reveal/draw shapes and hooks for smoother entrance/draw animations * **Documentation** * Expanded animation guide with interactive matching and custom-animation examples * New animation API category and multiple live example demos * **Tests** * Comprehensive test coverage for animation utilities, managers, hooks, and example behaviors <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/recharts/recharts/pull/7215?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Copilot <[email protected]>
Description
Adds an
animationMatchByprop toLineandAreathat controls how points are matchedbetween data updates during animation. Instead of the default index-based stretching, points
can be matched by identity (x-coordinate, data key, or custom function) so that existing
points slide smoothly to new positions, new points animate in from the edge, and removed
points slide off-screen.
Also adds
animationClipOverflowto clip points that slide beyond axis bounds, andisAnimationActivetoReferenceLineandReferenceAreafor smooth position transitions.Related Issue
Closes #1961
#1997
#2575
Motivation and Context
When datapoints are added/removed from a chart, the default index-based animation interpolates
every point to the wrong position — the line "dances" instead of sliding. This has been a
long-standing issue forcing users to disable animation entirely for time-series data.
animationMatchBysolves this by matching points by identity rather than array index.Types of changes
Checklist
Summary by CodeRabbit