Skip to content

feat: move markers along paths natively - #110

Merged
lodev09 merged 4 commits into
mainfrom
jov/ENG-1719-marker-path
Nov 10, 2026
Merged

lodev09 merged 4 commits into
mainfrom
jov/ENG-1719-marker-path

Conversation

@lodev09

@lodev09 lodev09 commented Nov 10, 2026

Copy link
Copy Markdown
Member

Summary

Moves markers natively, without a JS update on every frame, so large fleets and live GPS tracking stay smooth even while JS is busy (for example while the map is being dragged). Builds on @erickreutz's native path engine (cfc53a5, ca01ccf) and simplifies the public API on top of it (46c87fa).

// Planned routes: constant speed, faces each segment
<Marker
  path={{ coordinates: route, duration: 60_000, loop: true }}
  rotateAlongPath
  onPathProgress={(e) => setLeg(e.nativeEvent.index)}
/>

// Live GPS: glide to each fix over the fix interval
<Marker coordinate={fix.coordinate} rotate={fix.course} transitionDuration={3000} />

Marker

  • path: { coordinates, duration | times, rotations?, startTime?, loop? }. With duration, time is spread by distance, so the marker moves at a constant speed. startTime defaults to when the path is set.
  • rotateAlongPath: faces the direction of travel and settles each turn within 300 ms of the corner.
  • transitionDuration: each new coordinate glides from where the marker is now. It's built as a two-point path.
  • onPathProgress: fires when the marker passes each keyframe, and once when the path starts. It runs on a JS timer, one timeout per keyframe.

MapView

  • idleFrameRate: 'maximum' | 'conservative' | 'power-save'. Lowers the redraw rate while the camera is still.

Performance fixes

  • iOS (Apple and Google): position-only updates no longer re-render the marker icon. Reanimated-driven markers on Apple Maps went from 78% to 47% main-thread CPU, with Fleet 50.
  • Google Maps (iOS and Android): marker settings are only sent when they change. The SDK logs every setter call, so repeated calls were expensive.

Linear: ENG-1719

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Test Plan

All on the iOS simulator and Android emulator, Debug builds.

  • Example app: use Fleet (0/10/50), Motion (Path/Reanimated), Tracking and Idle fps.
  • Unit tests: src/__tests__/markerPath.test.ts covers keyframe resolution, distance timing, turn keyframes, transitions from mid-move, and progress events.
  • Profiling, Fleet 50, % of one CPU core:
Dragging the map Apple main Apple JS Google main Google JS
path 33% 17% 29% 39%
Per-segment JS updates (alternative we tried) 31% 31% 35% 50%
  • iOS: ran Path mode and Tracking on Apple Maps with the final API. The trucks follow their routes facing the road, and the route line behind the main truck trims as it moves.
  • Android: builds, but I haven't run the final API on it yet. I profiled the earlier version.
  • Web: typechecks, but I haven't run it.
  • Google Maps on iOS: not yet checked at runtime with the final API.

Screenshots / Videos

TODO

Checklist

  • I tested on iOS
  • I tested on Android
  • I tested on Web
  • I updated the documentation (if needed)

erickreutz and others added 3 commits November 9, 2026 14:39
Marker takes a `path` (keyframe coordinates, times, optional rotations,
a wall-clock start time, loop) and native code moves it: Core Animation
keyframes on Google Maps for iOS, a frame timer on Apple Maps and
Android, and requestAnimationFrame on web. Each segment turns the short
way. `pathPositionAt` gives the same position in JS.

MapView takes `idleFrameRate`: on Google Maps for iOS the map renders at
60, 30 or 15 fps while the camera is still and at full rate while it
moves; on Apple Maps and Android it sets how often path markers move.
- path: duration (constant speed) or times, optional startTime
- rotateAlongPath, onPathProgress, transitionDuration built on path
- Drop pathPositionAt export; idleFrameRate 'power-save'
- Skip icon re-rasterize on position-only updates (iOS)
- Only set changed Google marker values (iOS, Android)
@vercel

vercel Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
lugg-maps Ready Ready Preview Nov 10, 2026 7:35pm UTC

Request Review

@erickreutz erickreutz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ran this branch in an app with 24 path markers that use custom times and rotations, plus idleFrameRate:

  • iPhone Air, Release, Google Maps: works. With the camera still, app CPU was 20.1% plus 10.2% for backboardd, against 19.6–19.8% plus 8.8–9.0% on eric/marker-path-prototype. That's one 30 s run, so no change.
  • Android emulator (API 36), debug: works. Markers move, with no JS errors or crashes in logcat.
  • Web (production export): works, with no console errors.

The app needed two changes: 'power-save', and the typing note on Marker.types.ts.

Comment thread src/components/markerPath.ts Outdated
now = Date.now()
): NativeMarkerPath | undefined {
if (path) {
if (path !== this.source || rotateAlongPath !== this.rotateAlongPath) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P1] A new path object restarts the marker, because the default startTime is stamped whenever the object changes.

The example in the PR description does this: path={{ coordinates: route, duration: 60_000, loop: true }} with onPathProgress={(e) => setLeg(e.nativeEvent.index)}. When the marker passes keyframe 1, setLeg(1) re-renders the parent. The inline object is new, so resolvePath stamps startTime = now, and the marker jumps back to keyframe 0. The restart emits index 0, and the cycle repeats at every pass, so the marker never gets past its second point. I reproduced it with this file: resolving the same route again 31 s in puts the marker back at the first keyframe.

The docs do say to keep the object stable, but inline objects are the normal React habit, and nothing warns when it goes wrong. Smallest fix: compare the fields instead of the wrapper object, so a stable route inside an inline object keeps its start time.

const samePath = (a: MarkerPath, b?: MarkerPath) =>
  !!b &&
  a.coordinates === b.coordinates &&
  a.times === b.times &&
  a.duration === b.duration &&
  a.rotations === b.rotations &&
  a.startTime === b.startTime &&
  a.loop === b.loop;


- (void)markerViewDidUpdate:(LuggMarkerView *)markerView {
[self syncMarkerView:markerView];
[self syncMarkerView:markerView restyle:markerView.styleChanged];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] On Google Maps, skipping the icon redraw on position-only updates measured worse on a real phone.

I tried the same change on an iPhone Air in Release. There were 24 markers moved by Reanimated at 30 Hz, the icon was re-created only when the scale changed, and rotation was set only when it changed. App CPU went from 47–51% to 63%, and backboardd went from 23–26% to 21%. Time Profiler showed the map drawing about 2.4× as much without a new icon on each update, and CATransaction setDisableActions: only partly helped. It paid off only together with preferredFrameRate = kGMSFrameRatePowerSave (38% plus 10%).

The 78% → 47% in the description is Apple Maps on the simulator in Debug. Path markers don't hit this, since they get no position updates. Any marker moved by Reanimated or JS does, such as a live-tracking marker. Can we keep the gate for Apple Maps only, or measure Google on a device in Release before merging?

const fromRotation = at?.rotation ?? last.rotate;
const to = { latitude, longitude };
const moved = from.latitude !== latitude || from.longitude !== longitude;
const toRotation = !rotateAlongPath

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P2] With rotateAlongPath, any move turns the marker toward the new fix, however small. A parked vehicle's GPS fixes drift a few meters, so the marker spins to a new heading on every fix. The docs suggest this combination when fixes have no course, which is when it shows up most. The tracking demo on eric/marker-path-prototype hit the same thing, which is why it turned to the route's course instead of toward the fix.

Smallest fix: keep fromRotation unless the move is longer than a few meters.

* A route that a marker follows natively, with no React renders or prop updates per frame.
* Time it with `duration`, or with `times` for exact timing.
*/
export type MarkerPath = MarkerPathBase &

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[P3] With this union, code that builds a path with times and reads path.times or path.startTime back gets undefined in the type. A helper typed (): MarkerPath that tests its keyframes stopped typechecking on this branch. It now returns MarkerPath & { times: number[]; startTime: number }. A line in the docs, or an exported timed variant, would save the next person the detour.

@lodev09

lodev09 commented Nov 10, 2026 •

Copy link
Copy Markdown
Member Author

Thanks for the review and the device runs. Here is what we're doing with each point. The changes for 1, 3 and 4 are pushed in 0738957.

[P1] A new path object restarts the marker: fixed with your samePath check. The marker now restarts only when a field changes. Arrays compare by reference, so an inline path={{ coordinates: route, ... }} with a stable route keeps its start time. I added a test and updated the docs.

[P2] rotateAlongPath spins on GPS drift: fixed. A transitionDuration move shorter than 5 m keeps the current heading. I added a test and updated the docs.

[P3] The MarkerPath union hides times: I added a note to the docs and the skill to build the path with satisfies MarkerPath instead of a MarkerPath return type. I didn't export a new type.

[P2] Google iOS icon redraw: we're keeping the skip. I measured both builds in Release on an iPhone 17 Pro Max simulator, alternating them. The camera was still, and CPU is % of one core (app + backboardd):

Scenario Skip (this PR) Re-create icon each update
Google, Reanimated, 24 markers 55–58% + 16–18% 77–86% + 6%
Google, Reanimated, 50 markers 76% + 14% 93% + 4%
Google, path, 24 / 50 markers 25–29% + 7–9% 27–29% + 7–9%
Apple (no native change, control) 37–39% + 8–9% 37–38% + 8–9%

Time Profiler, Google, Reanimated, 50 markers, main thread:

Skip Re-create
Main thread 64% 87%
GMSVectorMapView draw 36% 17%
createScaledIconImage 4% 60%
Main run loop waits per second 282 120

I see the same thing you saw: the map draws about 2× as much when we skip the icon. But re-creating the icon fills the main thread, and the run loop goes idle less than half as often. I think the extra drawing is the map rendering more frames, not more work per frame. Side by side, the build that skips the icon is clearly smoother.

Confirmed on a device: we installed both Release builds side by side on an iPhone 17 Pro Max (Google Maps, Reanimated fleet), and the build that re-creates the icon on each update is visibly laggy. The build that skips it is smooth. I couldn't get clean CPU numbers on the phone, because it reached a "Serious" thermal state during the automated runs.

@lodev09
lodev09 marked this pull request as ready for review November 10, 2026 19:36
@lodev09
lodev09 merged commit d8a1afc into main Nov 10, 2026
5 checks passed
@lodev09
lodev09 deleted the jov/ENG-1719-marker-path branch November 10, 2026 19:37
@lodev09 lodev09 mentioned this pull request Nov 10, 2026
1 of 8 tasks

This branch was successfully deployed

1 active deployment
Preview — 0738957e Deployed Nov 10, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants