Skip to content

feat: Smooth route fit, draw animation, and vehicle movement on the map#518

Open
Ahmedhossamdev wants to merge 5 commits into
developfrom
feature/map-route-and-vehicle-animations
Open

feat: Smooth route fit, draw animation, and vehicle movement on the map#518
Ahmedhossamdev wants to merge 5 commits into
developfrom
feature/map-route-and-vehicle-animations

Conversation

@Ahmedhossamdev

@Ahmedhossamdev Ahmedhossamdev commented Jun 16, 2026

Copy link
Copy Markdown
Member

Improves the map UX for route display and real-time vehicles.

Changes

  • Fit to route bounds: new fitToPolylines() on both map providers centers the full route with padding and a max-zoom cap, replacing the hardcoded zoom in RouteMap and SearchPane (arrival --> trip, and "view all routes" flows).
  • Smooth, glitch-free reveal (OSM): the route is hidden during the flyToBounds glide (avoids the MapLibre GL vs SVG desync), then "draws" itself start --> end via stroke-dashoffset.
  • Synced stop markers: stop markers now appear with the route reveal (after the camera settles) and fade in, instead of popping in first.
  • No flicker on stop focus: flyTo accepts { animate: false }; the route stop list uses it so the route stays glued to the basemap.
  • Animated vehicles: new animateMarker helper interpolates vehicle markers between position updates (~1.2s ease-out) so they glide along the route instead of teleporting; animations are cancelled on marker removal.

Summary by CodeRabbit

  • New Features
    • Route stop markers now fade in smoothly.
    • Vehicle markers animate along route geometry.
    • Maps can fit the viewport to the currently displayed route shapes.
  • Improvements
    • Route camera transitions are now driven by polyline fitting when available, with robust midpoint fallbacks.
    • Clicking stops keeps the route visually stable by disabling transition animation.
    • Vehicle animation updates cancel cleanly when markers are removed/cleared.
    • Locale switching now handles both promise and non-promise outcomes consistently.
  • Tests
    • Expanded animate-marker test coverage to validate snapping, cancellation, and frame-based animation behavior.

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a41b9890-057b-46fc-86be-2a0939c36fb0

📥 Commits

Reviewing files that changed from the base of the PR and between 37dc76d and b1adad8.

📒 Files selected for processing (4)
  • src/lib/Provider/GoogleMapProvider.svelte.js
  • src/lib/Provider/OpenStreetMapProvider.svelte.js
  • src/lib/i18n.js
  • src/tests/lib/animateMarker.test.js
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/lib/i18n.js
  • src/lib/Provider/GoogleMapProvider.svelte.js
  • src/lib/Provider/OpenStreetMapProvider.svelte.js

📝 Walkthrough

Walkthrough

This PR introduces route-aware vehicle marker animation in a new animateMarker.js utility with geospatial projection and animation control primitives. Both GoogleMapProvider and OpenStreetMapProvider are updated to animate vehicle movement along route shapes and provide new camera APIs (fitToPolylines, flyTo options). OpenStreetMapProvider additionally implements polyline visibility toggling and progressive draw animation via SVG stroke-dashoffset. Three route components (SearchPane, RouteMap, RouteModal) are refactored to use the new camera APIs with fallback behavior. A separate i18n Promise error-handling fix is also included.

Changes

Route Animation and Map Camera Improvements

Layer / File(s) Summary
animateMarker utility: geometry, path-building, animation loop and tests
src/lib/MapHelpers/animateMarker.js, src/tests/lib/animateMarker.test.js
New file defines geospatial projection helpers, buildRoutePath for route-following waypoint construction, cumulative-distance interpolation, cancelMarkerAnimation/runAnimation primitives, and the orchestrating animateMarkerTo export. Comprehensive tests cover corner-following, reversal, off-route null cases, multi-shape selection, frame cancellation, and cubic easing interpolation.
Provider vehicle marker animation and route path extraction
src/lib/Provider/GoogleMapProvider.svelte.js, src/lib/Provider/OpenStreetMapProvider.svelte.js
Both providers import animateMarkerTo and cancelMarkerAnimation. updateVehicleMarker now animates from the marker's current position to the new vehicle coordinates using _getRoutePaths() for route shaping. Marker removal and bulk clearing cancel in-flight animations before detaching. _getRoutePaths converts tracked polylines into coordinate arrays for route-following.
Polyline creation, removal, and timeout management
src/lib/Provider/GoogleMapProvider.svelte.js, src/lib/Provider/OpenStreetMapProvider.svelte.js
Both providers document the polyline decode contract (throws vs returning null). OpenStreetMapProvider removes and clears pending _drawTimeoutId timers during single-polyline removal and full polyline clearing to prevent animation memory leaks.
Provider camera APIs: flyTo options, fitToPolylines, and polyline animation
src/lib/Provider/GoogleMapProvider.svelte.js, src/lib/Provider/OpenStreetMapProvider.svelte.js
GoogleMapProvider adds fitToPolylines (bounds computation, maxZoom clamping on idle) and accepts a fourth _options parameter in flyTo. OpenStreetMapProvider extends flyTo with options.animate, adds _fitToken for stale reveal prevention, _setPolylinesVisible for visibility toggling, _revealPolylinesWithDraw for SVG stroke-dashoffset animation with delayed arrow decorators, and fitToPolylines (hides polylines during camera movement, reveals with timed draw sequence).
Route stop marker styling and animation
src/lib/Provider/OpenStreetMapProvider.svelte.js, src/assets/styles/leaflet-map.css
OpenStreetMapProvider assigns className: 'route-stop-marker' in the stop-route marker divIcon configuration. CSS stylesheet adds .route-stop-marker with opacity fade-in animation via @keyframes route-stop-marker-fade-in, transitioning from fully transparent to fully opaque over 0.3s.
Component camera flow: fitToPolylines with midpoint fallback
src/components/search/SearchPane.svelte, src/components/map/RouteMap.svelte, src/components/routes/RouteModal.svelte, src/components/routes/__tests__/RouteModal.test.js
SearchPane.handleRouteClick now awaits createPolyline for each route polyline before calling fitToPolylines, falling back to midpoint flyTo at zoom 12. RouteMap.loadRouteData replaces unconditional midpoint flyTo with fitToPolylines and falls back at zoom 13. RouteModal.handleStopItemClick passes { animate: false } to flyTo; tests updated to assert the new fourth argument.

i18n Locale Error Handling Fix

Layer / File(s) Summary
Locale set Promise.resolve wrap
src/lib/i18n.js
Promise.resolve(locale.set(preferredLocale)).catch(...) replaces direct .catch(...) to handle both the promise-returning and undefined-returning cases of locale.set without unhandled rejections.

Sequence Diagram(s)

sequenceDiagram
    participant Component as SearchPane / RouteMap
    participant Provider as GoogleMapProvider / OpenStreetMapProvider
    participant animateMarker as animateMarker.js

    rect rgba(70, 130, 180, 0.5)
        note over Component,Provider: Route load & camera fit
        Component->>Provider: createPolyline (awaited)
        Component->>Provider: fitToPolylines()
        Provider-->>Component: Promise resolves (or false)
        alt fit failed
            Component->>Provider: flyTo(midLat, midLng, zoom)
        end
    end

    rect rgba(60, 179, 113, 0.5)
        note over Provider,animateMarker: Vehicle position update
        Provider->>Provider: _getRoutePaths()
        Provider->>animateMarker: animateMarkerTo(marker, from, to, setPosition, {routePaths})
        animateMarker->>animateMarker: buildRoutePath(routePaths, from, to)
        animateMarker->>animateMarker: runAnimation via rAF (cubic ease-out)
        animateMarker-->>Provider: marker reaches destination
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

  • Improve route shape transition animation in RouteMap #407: The changes implement smooth polyline animation and route-aware vehicle marker movement by adding fitToPolylines methods with progressive "draw" animations (SVG stroke-dashoffset), updating RouteMap.svelte and SearchPane.svelte to use polyline-driven camera positioning, and adding fade-in styling for route stop markers—addressing the original feature request for animated route rendering and vehicle motion.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main improvements: smooth route fitting, draw animation, and vehicle movement. It directly reflects the core changes across map providers and components.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/map-route-and-vehicle-animations

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@aaronbrethorst aaronbrethorst changed the base branch from main to develop June 16, 2026 15:38
@coveralls

coveralls commented Jun 16, 2026

Copy link
Copy Markdown

Coverage Status

Coverage is 80.732%feature/map-route-and-vehicle-animations into develop. No base build found for develop.

@Ahmedhossamdev Ahmedhossamdev force-pushed the feature/map-route-and-vehicle-animations branch from 7a3d49b to 1c16e5a Compare June 16, 2026 16:10

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
src/lib/Provider/OpenStreetMapProvider.svelte.js (2)

591-626: 💤 Low value

Stale callback risk if polylines are cleared during the draw animation.

The setTimeout at line 617 references path and polyline.arrowDecorator which may have been removed if clearAllPolylines() is called during the animation. While not critical (worst case is a no-op or brief console warning), consider tracking active timeouts for cleanup.

💡 Optional defensive guard
 			setTimeout(() => {
+				// Bail if the polyline was removed mid-animation.
+				if (!this.polylines.includes(polyline)) return;
 				// Clear the inline styles so the original stroke (e.g. the dashed
 				// pattern used for walking legs) is restored once drawing is done.
 				path.style.transition = '';
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/Provider/OpenStreetMapProvider.svelte.js` around lines 591 - 626, The
_revealPolylinesWithDraw method creates setTimeout callbacks that reference path
and polyline.arrowDecorator, but these references can become stale if
clearAllPolylines() is called during the animation. Store the timeout ID created
in the setTimeout call (around line 617) in a collection on the polyline object
or a class-level map, then add cleanup logic to cancel pending timeouts when
polylines are removed or cleared. This ensures that if clearAllPolylines()
executes during an animation, any outstanding timeouts for those polylines are
canceled before they can attempt to access already-removed DOM elements or
decorator objects.

628-674: 💤 Low value

Fallback timer may fire after polylines are cleared, causing stale state access.

If the user navigates away or triggers clearAllPolylines() before the moveend event or the 250ms fallback fires, reveal() will call _revealPolylinesWithDraw on an empty this.polylines array. This is harmless (no-op loop) but the Promise resolves with true even though nothing was revealed.

Consider guarding or returning early in reveal() if this.polylines.length === 0.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/Provider/OpenStreetMapProvider.svelte.js` around lines 628 - 674, The
`reveal()` callback function inside the `fitToPolylines()` method can execute
after polylines are cleared, causing it to call `_revealPolylinesWithDraw()` on
an empty array and incorrectly resolve the Promise with `true`. Add an early
return guard at the start of the `reveal()` function to check if
`this.polylines.length === 0`, and if so, return early or resolve with `false`
instead, ensuring the Promise accurately reflects whether any polylines were
actually revealed.
src/lib/Provider/GoogleMapProvider.svelte.js (1)

591-622: 💤 Low value

Idle listener is not removed if fitBounds resolves synchronously or the map is destroyed.

The addListenerOnce should handle most cases, but if the map is destroyed before idle fires, the listener and Promise remain dangling. Consider adding a cleanup mechanism or documenting this limitation.

However, since addListenerOnce self-removes after firing, the practical risk is low for normal usage patterns.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/Provider/GoogleMapProvider.svelte.js` around lines 591 - 622, The
fitToPolylines method adds an idle event listener that may not be removed if the
map is destroyed before the idle event fires, potentially leaving a dangling
listener and unresolved Promise. To fix this, store the listener ID returned by
addListenerOnce and add a cleanup mechanism that removes the listener if the map
becomes unavailable or is destroyed before the idle event fires. This could be
implemented by either checking if the map still exists before resolving or by
adding a destroy/cleanup handler to the map instance that removes the listener
and rejects the Promise if triggered before the idle event fires.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/search/SearchPane.svelte`:
- Around line 128-132: The polylines array is being appended to repeatedly in
handleRouteClick without being cleared first, causing stale polyline references
to accumulate across multiple route selections. Before the for loop that
iterates over polylinesData and calls mapProvider.createPolyline, clear the
polylines array by resetting it to an empty array. This ensures that each route
click rebuilds the polylines collection from scratch rather than appending to
previous values.

---

Nitpick comments:
In `@src/lib/Provider/GoogleMapProvider.svelte.js`:
- Around line 591-622: The fitToPolylines method adds an idle event listener
that may not be removed if the map is destroyed before the idle event fires,
potentially leaving a dangling listener and unresolved Promise. To fix this,
store the listener ID returned by addListenerOnce and add a cleanup mechanism
that removes the listener if the map becomes unavailable or is destroyed before
the idle event fires. This could be implemented by either checking if the map
still exists before resolving or by adding a destroy/cleanup handler to the map
instance that removes the listener and rejects the Promise if triggered before
the idle event fires.

In `@src/lib/Provider/OpenStreetMapProvider.svelte.js`:
- Around line 591-626: The _revealPolylinesWithDraw method creates setTimeout
callbacks that reference path and polyline.arrowDecorator, but these references
can become stale if clearAllPolylines() is called during the animation. Store
the timeout ID created in the setTimeout call (around line 617) in a collection
on the polyline object or a class-level map, then add cleanup logic to cancel
pending timeouts when polylines are removed or cleared. This ensures that if
clearAllPolylines() executes during an animation, any outstanding timeouts for
those polylines are canceled before they can attempt to access already-removed
DOM elements or decorator objects.
- Around line 628-674: The `reveal()` callback function inside the
`fitToPolylines()` method can execute after polylines are cleared, causing it to
call `_revealPolylinesWithDraw()` on an empty array and incorrectly resolve the
Promise with `true`. Add an early return guard at the start of the `reveal()`
function to check if `this.polylines.length === 0`, and if so, return early or
resolve with `false` instead, ensuring the Promise accurately reflects whether
any polylines were actually revealed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f0dc5655-acb8-493d-977c-005f831944b4

📥 Commits

Reviewing files that changed from the base of the PR and between f486e54 and 1c16e5a.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (10)
  • src/assets/styles/leaflet-map.css
  • src/components/map/RouteMap.svelte
  • src/components/routes/RouteModal.svelte
  • src/components/routes/__tests__/RouteModal.test.js
  • src/components/search/SearchPane.svelte
  • src/lib/MapHelpers/animateMarker.js
  • src/lib/Provider/GoogleMapProvider.svelte.js
  • src/lib/Provider/OpenStreetMapProvider.svelte.js
  • src/lib/i18n.js
  • src/tests/lib/animateMarker.test.js

Comment thread src/components/search/SearchPane.svelte

@aaronbrethorst aaronbrethorst 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.

Hi Ahmed — this is a really enjoyable PR to review. The map has needed exactly this kind of polish for a long time, and you've gone after it thoughtfully: the route now frames itself to its full extent, draws itself in, and vehicles glide along the shape instead of teleporting. The buildRoutePath projection math is genuinely nice work, and it's backed by tests that read well and cover the tricky cases (corner-following, reversal, off-route rejection, multi-shape selection). The comments throughout are unusually careful and accurate — I checked the load-bearing ones against the actual library source and they hold up.

There's one issue that has to be fixed before this can merge: the Google fitToPolylines implementation can hang the entire route view with no error surfaced. Details below.

Critical Issues (1 found)

  • GoogleMapProvider.fitToPolylines() can hang forever — no timeout fallback. [src/lib/Provider/GoogleMapProvider.svelte.js:603-625]
    The returned Promise resolves only from inside the one-shot idle listener. Google Maps does not guarantee an idle event when fitBounds produces no visible viewport change (bounds already fitted, a degenerate single-point bound that survives the isEmpty() check, or the map mid-gesture). If idle never fires, the Promise never settles.

    Both callers await this and gate everything downstream on it:

    • SearchPane.handleRouteClick [src/components/search/SearchPane.svelte:138] — showStopsOnRoute (line 146) and fetchAndUpdateVehicles (line 152) never run.
    • RouteMap.loadRouteData [src/components/map/RouteMap.svelte:70] — the stop-marker loop (78-83) and vehicle polling (86) never run.

    The result is a textbook silent failure: no throw (so the surrounding try/catch never fires), no log, no feedback — just a half-drawn route with no stop markers and no vehicles. Your own OSM sibling already guards this exact scenario with a setTimeout fallback [OpenStreetMapProvider.svelte.js:664-666], which is what makes the omission on the Google side look like an oversight. Mirror it, with a settled guard to avoid a double-resolve:

    return new Promise((resolve) => {
        let settled = false;
        const finish = () => {
            if (settled) return;
            settled = true;
            if (this.map.getZoom() > maxZoom) this.map.setZoom(maxZoom);
            resolve(true);
        };
        window.google.maps.event.addListenerOnce(this.map, 'idle', finish);
        setTimeout(finish, 1000); // safety net if `idle` never fires
        this.map.fitBounds(bounds, options.padding ?? 50);
    });

Important Issues (3 found)

  • Un-cancelled setTimeout in _revealPolylinesWithDraw fires against polylines removed mid-draw. [src/lib/Provider/OpenStreetMapProvider.svelte.js:617-624]
    The per-polyline timeout that clears inline styles and adds the arrow decorator is never tracked or cancelled. Both callers start a new route load with clearAllPolylines() (which runs polyline.remove()). If a user clicks a second route within the ~1.2s draw window, the pending callback runs addDecorator() against a polyline that was just cleared, leaving a stray arrow decorator with no backing line. No crash, but an intermittent ghost-arrow on rapid route switching that's hard to diagnose. Store the timer id on the polyline and clearTimeout it in removePolyline/clearAllPolylines, and bail out of the callback if !this.map.hasLayer(polyline).

  • OSM fitToPolylines reveal closure can draw the wrong route after a fast switch. [src/lib/Provider/OpenStreetMapProvider.svelte.js:654-666]
    reveal() calls _revealPolylinesWithDraw, which iterates this.polylines — the live array, not a snapshot. If route B loads before route A's moveend/fallback fires, A's pending reveal draws B's polylines (or the now-empty set). The revealed guard only dedupes A's own two triggers; it doesn't protect against a superseded load. Capture the polyline set (or a load token) when fitToPolylines starts and no-op the reveal if it no longer matches.

  • The animation runtime is essentially untested. [src/tests/lib/animateMarker.test.js]
    buildRoutePath is well covered, but the module's actual entry point — animateMarkerTo — and cancelMarkerAnimation have no tests, and neither does the cumulative-distance interpolation (measurePath/positionAlong) that decides whether the glide looks correct. These are cheap to add with the patterns already in this repo (vi.useFakeTimers, vi.stubGlobal('requestAnimationFrame', …)). Worth covering before merge:

    1. cancelMarkerAnimation — calls cancelAnimationFrame with the stored id, nulls _animationFrameId, and no-ops on a null marker (the removeVehicleMarker path can hit that).
    2. animateMarkerTo instant-move fallback — from === to and no-requestAnimationFrame both call setPosition(to…) once and schedule no frame.
    3. Interpolation end-state — the final frame lands exactly on to, a midpoint on an L-shaped route sits on the geometry (not the diagonal), and a zero-length path doesn't divide by zero.

    A test asserting fitToPolylines resolves even when no camera move occurs would also have caught the Critical issue above. The provider methods that depend on live Leaflet/Google globals (_revealPolylinesWithDraw, the bounds math) are fine to leave untested — that's consistent with the rest of the repo.

Suggestions (3 found)

  • Make the Google flyTo options contract explicit. [src/lib/Provider/GoogleMapProvider.svelte.js:580-586] The comment correctly notes the trailing options arg is "harmlessly ignored," but relying on silent arg-dropping rots if the OSM signature changes. Consider flyTo(lat, lng, zoom = 15, _options = {}) so the shared interface is enforced rather than coincidental.
  • Normalize the createPolyline null-vs-throw contract. OSM createPolyline returns null on decode failure; Google throws. Both are handled safely in the current call paths, but the asymmetry means a future caller can't assume a uniform contract. Worth documenting or normalizing.
  • The 7-line i18n comment is at the verbose end. [src/lib/i18n.js:118-127] Accurate and the nuance is real, but the parenthetical en-US-closest-matching example is the load-bearing part; the rest could be trimmed.

Strengths

  • buildRoutePath and the projection helpers are sound — equirectangular scaling is appropriate over short vehicle-update spans, segment projection clamps correctly, and forward/backward vertex walking handles the same-segment case.
  • Animation cleanup is good defensive design: cancelMarkerAnimation runs before each re-animation and on every marker removal/clear path on both providers — no rAF leaks.
  • The off-route / null-shape fallbacks degrade gracefully to straight-line interpolation; the marker always moves.
  • The i18n Promise.resolve(locale.set(...)) fix is correct (verified against svelte-i18n@4.0.1 source) and properly tested.
  • The OSM "hide route during the glide, draw it once the basemap settles" approach is a clever, correctly-reasoned fix for the MapLibre GL / SVG desync.

Recommended Action

  1. Fix the Critical Google fitToPolylines hang (add the setTimeout + settled guard).
  2. Address the two OSM rapid-switch races (un-cancelled timer, stale reveal closure).
  3. Add the three animation-runtime tests.
  4. Consider the suggestions.
  5. Re-run the review after fixes.

Verdict: Request Changes — the route view can permanently hang on the Google provider with no error surfaced, and that path is reachable in normal use.

@Ahmedhossamdev

Copy link
Copy Markdown
Member Author

GoogleMapProvider.fitToPolylines() can hang forever — no timeout fallback. [src/lib/Provider/GoogleMapProvider.svelte.js:603-625]
The returned Promise resolves only from inside the one-shot idle listener. Google Maps does not guarantee an idle event when fitBounds produces no visible viewport change (bounds already fitted, a degenerate single-point bound that survives the isEmpty() check, or the map mid-gesture). If idle never fires, the Promise never settles.

Fair point, I'm working on the fix.

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.

3 participants