Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions android/src/main/java/com/luggmaps/core/PolylineAnimator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/content/docs/components/polyline.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -54,6 +54,8 @@ interface PolylineAnimatedOptions {
}
```

On web, the final animation frame remains visible during the delay between loops.

```tsx
<Polyline
coordinates={coordinates}
Expand Down
16 changes: 14 additions & 2 deletions ios/core/GMSPolylineAnimator.m
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ - (void)tick:(CADisplayLink *)displayLink {
@implementation GMSPolylineAnimator {
CADisplayLink *_displayLink;
GMSDisplayLinkProxy *_displayLinkProxy;
CFTimeInterval _lastTickTimestamp;
CGFloat _animationProgress;
CGFloat _delayRemaining;
NSArray<NSNumber *> *_cumulativeDistances;
Expand Down Expand Up @@ -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];
}

Expand Down Expand Up @@ -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;
}

Expand All @@ -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) {
Expand Down
16 changes: 14 additions & 2 deletions ios/core/MKPolylineAnimator.m
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ @implementation MKPolylineAnimator {
MKPolyline *_polyline;
CADisplayLink *_displayLink;
MKDisplayLinkProxy *_displayLinkProxy;
CFTimeInterval _lastTickTimestamp;
CGFloat _animationProgress;
CGFloat _delayRemaining;
NSArray<NSNumber *> *_cumulativeDistances;
Expand Down Expand Up @@ -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];
}

Expand Down Expand Up @@ -117,10 +122,12 @@ - (void)stopAnimation {

- (void)pause {
_displayLink.paused = YES;
_lastTickTimestamp = 0;
}

- (void)resume {
_displayLink.paused = NO;
_lastTickTimestamp = 0;
}

- (NSUInteger)indexForDistance:(CGFloat)distance
Expand Down Expand Up @@ -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;
}

Expand All @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions ios/core/PolylineAnimatorBase.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion skills/maps-usage/references/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,10 +217,12 @@ 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` |

On web, the final animation frame remains visible during the delay between loops.

No `onPress`. Polylines are `pointerEvents: 'none'`.

## Polygon
Expand Down
92 changes: 92 additions & 0 deletions src/__tests__/Polyline.web.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Polyline } from '../components/Polyline.web';

jest.mock('react', () => ({
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);
}
}
);
9 changes: 9 additions & 0 deletions src/components/Polyline.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -199,6 +201,7 @@ export const Polyline = memo(

let startTime: number | null = null;
let delayRemaining = delay;
let lastDrawn = -Infinity;

let pausedAt: number | null = null;

Expand Down Expand Up @@ -231,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) {
Expand Down
Loading