From b540d0d4bdbe917172ee69f2a0b33468270c28bb Mon Sep 17 00:00:00 2001 From: Eric Kreutzer Date: Mon, 9 Nov 2026 12:39:23 -0700 Subject: [PATCH 1/2] perf: cap animated polylines at 30 fps --- .../java/com/luggmaps/core/PolylineAnimator.kt | 7 ++++--- docs/content/docs/components/polyline.mdx | 2 +- ios/core/GMSPolylineAnimator.m | 16 ++++++++++++++-- ios/core/MKPolylineAnimator.m | 16 ++++++++++++++-- ios/core/PolylineAnimatorBase.h | 3 +++ skills/maps-usage/references/configuration.md | 2 +- src/components/Polyline.web.tsx | 9 +++++++++ 7 files changed, 46 insertions(+), 9 deletions(-) diff --git a/android/src/main/java/com/luggmaps/core/PolylineAnimator.kt b/android/src/main/java/com/luggmaps/core/PolylineAnimator.kt index b8e2062..fa8fdde 100644 --- a/android/src/main/java/com/luggmaps/core/PolylineAnimator.kt +++ b/android/src/main/java/com/luggmaps/core/PolylineAnimator.kt @@ -17,9 +17,10 @@ class PolylineAnimator { private const val MAX_ANIMATION_SPANS = 16 private const val MAX_GRADIENT_SPANS = 512 - // Cap polyline updates to ~60fps; every update crosses Binder into the - // maps renderer, and 120Hz displays double that churn (CONSUMER-APP-44Y) - private const val MIN_UPDATE_INTERVAL_NANOS = 16_000_000L + // Cap polyline updates to ~30fps; every update crosses Binder into the + // maps renderer and redraws the map, and 120Hz displays multiply that + // churn (CONSUMER-APP-44Y) + private const val MIN_UPDATE_INTERVAL_NANOS = 32_000_000L } var polyline: Polyline? = null diff --git a/docs/content/docs/components/polyline.mdx b/docs/content/docs/components/polyline.mdx index c9d95fc..b5d4f44 100644 --- a/docs/content/docs/components/polyline.mdx +++ b/docs/content/docs/components/polyline.mdx @@ -39,7 +39,7 @@ import { MapView, Polyline } from '@lugg/maps'; | `coordinates` | `Coordinate[]` | **required** | Array of coordinates | | `strokeWidth` | `number` | - | Line width in points | | `strokeColors` | `ColorValue[]` | - | Gradient colors along the line | -| `animated` | `boolean` | `false` | Animate the polyline with a snake effect | +| `animated` | `boolean` | `false` | Animate the polyline with a snake effect, redrawn at up to 30 fps | | `animatedOptions` | `PolylineAnimatedOptions` | - | Animation configuration options | | `zIndex` | `number` | - | Z-index for layering polylines | diff --git a/ios/core/GMSPolylineAnimator.m b/ios/core/GMSPolylineAnimator.m index a4e49a4..3b1f08d 100644 --- a/ios/core/GMSPolylineAnimator.m +++ b/ios/core/GMSPolylineAnimator.m @@ -32,6 +32,7 @@ - (void)tick:(CADisplayLink *)displayLink { @implementation GMSPolylineAnimator { CADisplayLink *_displayLink; GMSDisplayLinkProxy *_displayLinkProxy; + CFTimeInterval _lastTickTimestamp; CGFloat _animationProgress; CGFloat _delayRemaining; NSArray *_cumulativeDistances; @@ -82,6 +83,10 @@ - (void)startAnimation { _delayRemaining = self.animatedOptions.delay / 1000.0; _displayLinkProxy = [[GMSDisplayLinkProxy alloc] initWithTarget:self selector:@selector(animationTick:)]; _displayLink = [CADisplayLink displayLinkWithTarget:_displayLinkProxy selector:@selector(tick:)]; + _displayLink.preferredFrameRateRange = CAFrameRateRangeMake(kPolylineAnimationFrameRate, + kPolylineAnimationFrameRate, + kPolylineAnimationFrameRate); + _lastTickTimestamp = 0; [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; } @@ -109,15 +114,22 @@ - (void)stopAnimation { - (void)pause { _displayLink.paused = YES; + _lastTickTimestamp = 0; } - (void)resume { _displayLink.paused = NO; + _lastTickTimestamp = 0; } - (void)animationTick:(CADisplayLink *)displayLink { + // Step by the real time since the last tick: `duration` stays the display's refresh interval when the + // link runs slower than the display. + CFTimeInterval elapsed = _lastTickTimestamp > 0 ? MIN(displayLink.timestamp - _lastTickTimestamp, 0.1) : 0; + _lastTickTimestamp = displayLink.timestamp; + if (_delayRemaining > 0) { - _delayRemaining -= displayLink.duration; + _delayRemaining -= elapsed; return; } @@ -127,7 +139,7 @@ - (void)animationTick:(CADisplayLink *)displayLink { CGFloat trailLength = MAX(0.01, MIN(1.0, self.animatedOptions.trailLength)); CGFloat maxProgress = (trailLength < 1.0) ? 1.0 : 2.15; - CGFloat speed = displayLink.duration / duration; + CGFloat speed = elapsed / duration; _animationProgress += speed; if (_animationProgress >= maxProgress) { diff --git a/ios/core/MKPolylineAnimator.m b/ios/core/MKPolylineAnimator.m index 437d50a..84707e4 100644 --- a/ios/core/MKPolylineAnimator.m +++ b/ios/core/MKPolylineAnimator.m @@ -30,6 +30,7 @@ @implementation MKPolylineAnimator { MKPolyline *_polyline; CADisplayLink *_displayLink; MKDisplayLinkProxy *_displayLinkProxy; + CFTimeInterval _lastTickTimestamp; CGFloat _animationProgress; CGFloat _delayRemaining; NSArray *_cumulativeDistances; @@ -90,6 +91,10 @@ - (void)startAnimation { _delayRemaining = _animatedOptions.delay / 1000.0; _displayLinkProxy = [[MKDisplayLinkProxy alloc] initWithTarget:self selector:@selector(animationTick:)]; _displayLink = [CADisplayLink displayLinkWithTarget:_displayLinkProxy selector:@selector(tick:)]; + _displayLink.preferredFrameRateRange = CAFrameRateRangeMake(kPolylineAnimationFrameRate, + kPolylineAnimationFrameRate, + kPolylineAnimationFrameRate); + _lastTickTimestamp = 0; [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; } @@ -117,10 +122,12 @@ - (void)stopAnimation { - (void)pause { _displayLink.paused = YES; + _lastTickTimestamp = 0; } - (void)resume { _displayLink.paused = NO; + _lastTickTimestamp = 0; } - (NSUInteger)indexForDistance:(CGFloat)distance @@ -158,8 +165,13 @@ - (CGFloat)applyEasing:(CGFloat)t { } - (void)animationTick:(CADisplayLink *)displayLink { + // Step by the real time since the last tick: `duration` stays the display's refresh interval when the + // link runs slower than the display. + CFTimeInterval elapsed = _lastTickTimestamp > 0 ? MIN(displayLink.timestamp - _lastTickTimestamp, 0.1) : 0; + _lastTickTimestamp = displayLink.timestamp; + if (_delayRemaining > 0) { - _delayRemaining -= displayLink.duration; + _delayRemaining -= elapsed; return; } @@ -169,7 +181,7 @@ - (void)animationTick:(CADisplayLink *)displayLink { CGFloat trailLength = MAX(0.01, MIN(1.0, _animatedOptions.trailLength)); CGFloat maxProgress = (trailLength < 1.0) ? 1.0 : 2.15; - CGFloat speed = displayLink.duration / duration; + CGFloat speed = elapsed / duration; _animationProgress += speed; if (_animationProgress >= maxProgress) { diff --git a/ios/core/PolylineAnimatorBase.h b/ios/core/PolylineAnimatorBase.h index 0364e08..834e4ea 100644 --- a/ios/core/PolylineAnimatorBase.h +++ b/ios/core/PolylineAnimatorBase.h @@ -24,6 +24,9 @@ @end +// Animated polylines redraw at most this often; each redraw makes the map render a frame. +static const float kPolylineAnimationFrameRate = 30; + typedef struct { CGFloat r, g, b, a; } RGBAComponents; diff --git a/skills/maps-usage/references/configuration.md b/skills/maps-usage/references/configuration.md index 6513cc4..40f5f55 100644 --- a/skills/maps-usage/references/configuration.md +++ b/skills/maps-usage/references/configuration.md @@ -217,7 +217,7 @@ Callout rendering by platform: | `coordinates` | `Coordinate[]` | **required** | πŸŽπŸ…ΆπŸ€–πŸŒ | Path | | `strokeWidth` | `number` | β€” (`1` on web) | πŸŽπŸ…ΆπŸ€–πŸŒ | Points | | `strokeColors` | `ColorValue[]` | β€” | πŸŽπŸ…ΆπŸ€–πŸŒ | One color = solid, many = gradient spread evenly along the path | -| `animated` | `boolean` | `false` | πŸŽπŸ…ΆπŸ€–πŸŒ | Snake animation from start to end, looping. Static maps render it complete | +| `animated` | `boolean` | `false` | πŸŽπŸ…ΆπŸ€–πŸŒ | Snake animation from start to end, looping, redrawn at up to 30 fps. Static maps render it complete | | `animatedOptions` | `PolylineAnimatedOptions` | β€” | πŸŽπŸ…ΆπŸ€–πŸŒ | `{ duration?: 2150, easing?: 'linear' \| 'easeIn' \| 'easeOut' \| 'easeInOut', trailLength?: 1 (0–1), delay?: 0 }` | | `zIndex` | `number` | β€” | πŸŽπŸ…ΆπŸ€–πŸŒ | Layering. Web defaults animated polylines to `1` | diff --git a/src/components/Polyline.web.tsx b/src/components/Polyline.web.tsx index 6d74684..dd1a828 100644 --- a/src/components/Polyline.web.tsx +++ b/src/components/Polyline.web.tsx @@ -3,6 +3,8 @@ import { useMapContext } from '../MapProvider.web'; import type { PolylineProps, PolylineEasing } from './Polyline.types'; const DEFAULT_DURATION = 2150; +// Redraw an animated polyline at most ~30 times a second, like iOS and Android. +const MIN_FRAME_INTERVAL_MS = 32; const applyEasing = (t: number, easing: PolylineEasing = 'linear'): number => { switch (easing) { @@ -199,6 +201,7 @@ export const Polyline = memo( let startTime: number | null = null; let delayRemaining = delay; + let lastDrawn = -Infinity; let pausedAt: number | null = null; @@ -238,6 +241,12 @@ export const Polyline = memo( startTime = time; } + if (time - lastDrawn < MIN_FRAME_INTERVAL_MS) { + animationRef.current = requestAnimationFrame(animate); + return; + } + lastDrawn = time; + const clampedProgress = Math.min(rawProgress, renderMax); const easedProgress = applyEasing(clampedProgress / renderMax, easing) * renderMax; From 14b159e069af2d3b174cd7e5d947655d3396214b Mon Sep 17 00:00:00 2001 From: Jovanni Lo Date: Tue, 10 Nov 2026 03:59:35 +0800 Subject: [PATCH 2/2] fix(web): render polyline endpoint before loop delay --- docs/content/docs/components/polyline.mdx | 2 + skills/maps-usage/references/configuration.md | 2 + src/__tests__/Polyline.web.test.tsx | 92 +++++++++++++++++++ src/components/Polyline.web.tsx | 12 +-- 4 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/Polyline.web.test.tsx diff --git a/docs/content/docs/components/polyline.mdx b/docs/content/docs/components/polyline.mdx index b5d4f44..21aa47a 100644 --- a/docs/content/docs/components/polyline.mdx +++ b/docs/content/docs/components/polyline.mdx @@ -54,6 +54,8 @@ interface PolylineAnimatedOptions { } ``` +On web, the final animation frame remains visible during the delay between loops. + ```tsx ({ + memo: (component: unknown) => component, + useCallback: (callback: unknown) => callback, + useEffect: (effect: () => void) => effect(), + useMemo: (factory: () => unknown) => factory(), + useRef: (current: unknown) => ({ current }), + useState: (value: unknown) => [value, jest.fn()], +})); + +jest.mock('../MapProvider.web', () => ({ + useMapContext: () => ({ map: {}, isDraggingRef: { current: false } }), +})); + +const originalGoogle = global.google; + +afterEach(() => { + global.google = originalGoogle; + jest.restoreAllMocks(); +}); + +it.each([60, 90, 120, 144])( + 'renders the trail endpoint during the loop delay at %i Hz', + (refreshRate) => { + let nextFrame: FrameRequestCallback = () => {}; + let time = 0; + const draws: { time: number; path: google.maps.LatLngLiteral[] }[] = []; + const recordPath = (path: google.maps.LatLngLiteral[]) => { + draws.push({ time, path }); + }; + + jest + .spyOn(global, 'requestAnimationFrame') + .mockImplementation((callback) => { + nextFrame = callback; + return 1; + }); + jest.spyOn(global, 'cancelAnimationFrame').mockImplementation(() => {}); + global.google = { + maps: { + Polyline: jest.fn().mockImplementation(({ path }) => { + recordPath(path); + return { + setPath: recordPath, + setOptions: jest.fn(), + setMap: jest.fn(), + }; + }), + }, + } as unknown as typeof google; + + const coordinates = Array.from({ length: 1000 }, (_, i) => ({ + latitude: 0, + longitude: i / 1000, + })); + Polyline({ + coordinates, + animated: true, + animatedOptions: { duration: 1010, delay: 500, trailLength: 0.5 }, + }); + + for (let frame = 0; frame <= refreshRate * 1.8; frame++) { + time = (frame * 1000) / refreshRate; + nextFrame(time); + } + + const finalDraw = draws[draws.length - 1]!; + expect(finalDraw.path[finalDraw.path.length - 1]).toEqual({ + lat: 0, + lng: 0.999, + }); + expect(finalDraw.time).toBeLessThan(1550); + + for ( + let frame = Math.floor(refreshRate * 1.8) + 1; + frame <= refreshRate * 2.3; + frame++ + ) { + time = (frame * 1000) / refreshRate; + nextFrame(time); + } + + expect(draws[draws.length - 1]!.time).toBeGreaterThanOrEqual( + finalDraw.time + 500 + ); + expect(draws[draws.length - 1]!.path).not.toEqual(finalDraw.path); + for (let i = 1; i < draws.length; i++) { + expect(draws[i]!.time - draws[i - 1]!.time).toBeGreaterThanOrEqual(32); + } + } +); diff --git a/src/components/Polyline.web.tsx b/src/components/Polyline.web.tsx index dd1a828..ad30194 100644 --- a/src/components/Polyline.web.tsx +++ b/src/components/Polyline.web.tsx @@ -234,6 +234,12 @@ export const Polyline = memo( delayRemaining = 0; } + if (time - lastDrawn < MIN_FRAME_INTERVAL_MS) { + animationRef.current = requestAnimationFrame(animate); + return; + } + lastDrawn = time; + const rawProgress = (time - startTime) / duration; if (rawProgress >= cycleMax) { @@ -241,12 +247,6 @@ export const Polyline = memo( startTime = time; } - if (time - lastDrawn < MIN_FRAME_INTERVAL_MS) { - animationRef.current = requestAnimationFrame(animate); - return; - } - lastDrawn = time; - const clampedProgress = Math.min(rawProgress, renderMax); const easedProgress = applyEasing(clampedProgress / renderMax, easing) * renderMax;