Skip to content

Commit 42431b9

Browse files
feat(map): enhance route animation and add turn markers
Improves the user experience of the route preview by introducing a smoother, time-based animation and adding visual cues for turns. - Adds more fluid and consistent drawing effect. - Adds an `isPredicting` state to display a "Calculating route..." message, providing better feedback during API calls. - Introduces small, white circular markers on the map to visually indicate each turn along the calculated route.
1 parent e5e4bce commit 42431b9

File tree

2 files changed

+76
-43
lines changed

2 files changed

+76
-43
lines changed

frontend/src/components/LeafletMap.tsx

Lines changed: 69 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useEffect, useState } from 'react'
22
import L from 'leaflet'
33
import 'leaflet/dist/leaflet.css'
4-
import { List, Loader2 } from 'lucide-react'
4+
import {Loader2 } from 'lucide-react'
55

66

77
// Fix for default markers not showing in bundled environments
@@ -32,6 +32,21 @@ const createCustomIcon = (color: string) => {
3232
})
3333
}
3434

35+
// Create a small white dot icon for turn-by-turn markers
36+
const createTurnIcon = () => {
37+
const markerHtml = `
38+
<svg viewBox="0 0 12 12" width="12" height="12" style="filter: drop-shadow(0 1px 2px rgba(0,0,0,0.4));">
39+
<circle cx="6" cy="6" r="4" fill="#FFFFFF" stroke="#333333" stroke-width="1.5" />
40+
</svg>`
41+
42+
return L.divIcon({
43+
className: 'leaflet-turn-icon',
44+
html: markerHtml,
45+
iconSize: [12, 12],
46+
iconAnchor: [6, 6],
47+
})
48+
}
49+
3550
export type GeoPoint = {
3651
name?: string
3752
lat: number
@@ -42,6 +57,7 @@ interface LeafletMapProps {
4257
from?: GeoPoint | null
4358
to?: GeoPoint | null
4459
animateKey?: string | number
60+
isPredicting?: boolean
4561
}
4662

4763
interface RouteStep {
@@ -50,7 +66,7 @@ interface RouteStep {
5066
}
5167
}
5268

53-
export default function LeafletMap({ from, to, animateKey }: LeafletMapProps) {
69+
export default function LeafletMap({ from, to, animateKey, isPredicting }: LeafletMapProps) {
5470
const [isRouteLoading, setIsRouteLoading] = useState(false)
5571
const [routeSteps, setRouteSteps] = useState<RouteStep[]>([])
5672

@@ -65,7 +81,7 @@ export default function LeafletMap({ from, to, animateKey }: LeafletMapProps) {
6581
: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
6682

6783

68-
84+
6985
const tiles = L.tileLayer(tileUrl, {
7086
maxZoom: 19,
7187
attribution:
@@ -77,6 +93,7 @@ export default function LeafletMap({ from, to, animateKey }: LeafletMapProps) {
7793

7894
let markers: L.Marker[] = []
7995
let routeLayer: L.Polyline | L.GeoJSON | null = null
96+
let turnMarkers: L.Marker[] = []
8097

8198
const fitBoundsIfNeeded = () => {
8299
const points: L.LatLngExpression[] = []
@@ -109,6 +126,11 @@ export default function LeafletMap({ from, to, animateKey }: LeafletMapProps) {
109126
}
110127
}
111128

129+
const clearTurnMarkers = () => {
130+
turnMarkers.forEach(m => m.remove())
131+
turnMarkers = []
132+
}
133+
112134
const drawStraight = () => {
113135
if (!from || !to) return
114136
if (routeLayer) routeLayer.remove()
@@ -123,30 +145,54 @@ export default function LeafletMap({ from, to, animateKey }: LeafletMapProps) {
123145

124146
const animateRoute = (geoJsonData: any) => {
125147
if (routeLayer) routeLayer.remove();
148+
clearTurnMarkers();
126149

127150
const allCoords = geoJsonData.geometry.coordinates.flat(1).map((c: number[]) => L.latLng(c[1], c[0]));
128151
const animatedPolyline = L.polyline([], { color: '#2563eb', weight: 4, opacity: 0.95 }).addTo(map);
129152
routeLayer = animatedPolyline;
130153

131-
let i = 0;
132-
const step = () => {
133-
if (i < allCoords.length) {
134-
animatedPolyline.addLatLng(allCoords[i]);
135-
i++;
154+
const animationDuration = 750; // Animate over 750ms
155+
let startTime: number | null = null;
156+
157+
const step = (timestamp: number) => {
158+
if (!startTime) {
159+
startTime = timestamp;
160+
}
161+
162+
const progress = Math.min((timestamp - startTime) / animationDuration, 1);
163+
const pointsToShow = Math.floor(progress * allCoords.length);
164+
165+
// Only update if there are new points to show to avoid unnecessary re-renders
166+
if (pointsToShow > animatedPolyline.getLatLngs().length) {
167+
animatedPolyline.setLatLngs(allCoords.slice(0, pointsToShow));
168+
}
169+
170+
if (progress < 1) {
136171
requestAnimationFrame(step);
137172
} else {
138-
// Animation finished, bind the popup
173+
animatedPolyline.setLatLngs(allCoords); // Ensure the full route is drawn
139174
const properties = geoJsonData.properties;
140175
if (properties) {
141176
const distanceKm = (properties.distance / 1000).toFixed(1);
142177
const timeMinutes = Math.round(properties.time / 60);
143-
animatedPolyline.bindPopup(
144-
`<b>Route Details</b><br>Distance: ${distanceKm} km<br>Est. Time: ${timeMinutes} minutes`
145-
);
178+
animatedPolyline.bindPopup(`<b>Route Details</b><br>Distance: ${distanceKm} km<br>Est. Time: ${timeMinutes} minutes`);
179+
}
180+
181+
// Draw turn markers after animation is complete
182+
const steps = geoJsonData.properties?.legs?.[0]?.steps;
183+
if (steps && steps.length > 1) {
184+
const turnIcon = createTurnIcon();
185+
// Start from the second step to get the first turn coordinate
186+
for (let i = 1; i < steps.length; i++) {
187+
const turnIndex = steps[i].from_index;
188+
if (turnIndex < allCoords.length) {
189+
const turnMarker = L.marker(allCoords[turnIndex], { icon: turnIcon }).addTo(map);
190+
turnMarkers.push(turnMarker);
191+
}
192+
}
146193
}
147194
}
148195
};
149-
150196
requestAnimationFrame(step);
151197
};
152198

@@ -158,17 +204,18 @@ export default function LeafletMap({ from, to, animateKey }: LeafletMapProps) {
158204
return
159205
}
160206
setIsRouteLoading(true)
207+
clearTurnMarkers()
161208
setRouteSteps([]) // Clear previous steps
162209
try {
163-
//EXAMPLE:https://api.geoapify.com/v1/routing?waypoints=40.7757145,-73.87336398511545|40.6604335,-73.8302749&mode=drive&apiKey=YOUR_API_KEY
210+
//EXAMPLE:https://api.geoapify.com/v1/routing?waypoints=40.7757145,-73.87336398511545|40.6604335,-73.8302749&mode=drive&apiKey=YOUR_API_KEY
164211
const url = `https://api.geoapify.com/v1/routing?waypoints=${from.lat},${from.lon}|${to.lat},${to.lon}&mode=drive&format=geojson&apiKey=${apiKey}`
165212
console.log(url);
166213
const res = await fetch(url)
167214
if (!res.ok) throw new Error(`HTTP ${res.status}`)
168215
const data = await res.json()
169216
console.log('Geoapify route data:', data);
170217
if (!data?.features?.[0]) throw new Error('No route')
171-
218+
172219
animateRoute(data.features[0]);
173220

174221
// Extract and set turn-by-turn instructions
@@ -186,11 +233,11 @@ export default function LeafletMap({ from, to, animateKey }: LeafletMapProps) {
186233
fitBoundsIfNeeded()
187234
// Always draw something quickly, then try to replace with routed geometry
188235
if (from && to) {
189-
drawStraight()
190236
fetchRoute()
191237
}
192238

193239
return () => {
240+
clearTurnMarkers()
194241
map.remove()
195242
}
196243
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -201,7 +248,11 @@ export default function LeafletMap({ from, to, animateKey }: LeafletMapProps) {
201248
<div className="flex items-center justify-between border-b border-border px-4 py-3 text-sm text-foreground/70">
202249
<div className="flex items-center gap-2">
203250
<span className="inline-flex h-2 w-2 rounded-full bg-primary" />
204-
<span>Route Preview</span>
251+
{isPredicting ? (
252+
<span>Calculating route...</span>
253+
) : (
254+
<span>Route Preview</span>
255+
)}
205256
</div>
206257
{from && to ? (
207258
<span className="truncate">{from.name ?? 'Start'}{to.name ?? 'End'}</span>
@@ -217,21 +268,7 @@ export default function LeafletMap({ from, to, animateKey }: LeafletMapProps) {
217268
</div>
218269
)}
219270
</div>
220-
{routeSteps.length > 0 && (
221-
<div className="border-t border-border">
222-
<div className="flex items-center gap-2 px-4 py-2 text-sm font-medium">
223-
<List className="h-4 w-4" />
224-
<span>Turn-by-Turn Directions</span>
225-
</div>
226-
<ol className="max-h-48 overflow-y-auto list-decimal list-inside bg-background/50 px-4 pb-3 text-sm">
227-
{routeSteps.map((step, index) => (
228-
<li key={index} className="py-1.5 border-b border-border/50 last:border-b-0">
229-
{step.instruction.text}
230-
</li>
231-
))}
232-
</ol>
233-
</div>
234-
)}
271+
235272
</div>
236273
)
237274
}

frontend/src/pages/Home.tsx

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { DateTimePicker } from "@/components/DateTimePicker";
55
import { Button } from "@/components/ui/button";
66
import { ThemeToggle } from "@/components/ui/theme-toggle";
77
import { predictTravelTime } from "@/lib/api";
8-
import { Clock, MapPin, Car, Calendar, AlertTriangle ,Loader2} from "lucide-react";
8+
import { Clock, MapPin, Car, AlertTriangle ,Loader2} from "lucide-react";
99
import Footer from "@/components/Footer";
1010
import { motion, AnimatePresence } from "framer-motion";
1111

@@ -50,20 +50,11 @@ export default function Home() {
5050
const [predicted, setPredicted] = useState<number | null>(null);
5151
const [animKey, setAnimKey] = useState(0);
5252

53-
const getInitialDateTime = () => {
54-
const now = new Date();
55-
const year = now.getFullYear();
56-
const month = String(now.getMonth() + 1).padStart(2, '0');
57-
const day = String(now.getDate()).padStart(2, '0');
58-
const hours = String(now.getHours()).padStart(2, '0');
59-
const minutes = String(now.getMinutes()).padStart(2, '0');
60-
return `${year}-${month}-${day}T${hours}:${minutes}`;
61-
};
62-
const [dateStr, setDateStr] = useState(getInitialDateTime());
6353
const [currentCity, setCurrentCity] = useState<"new_york" | "san_francisco">(
6454
"new_york"
6555
);
6656
const [isLoading, setIsLoading] = useState(false);
57+
const [isPredicting, setIsPredicting] = useState(false);
6758
const [warning, setWarning] = useState(""); // Add this line
6859

6960
// Update city when location changes
@@ -118,6 +109,7 @@ export default function Home() {
118109
}
119110

120111
setIsLoading(true);
112+
setIsPredicting(true);
121113
const isMobile = window.innerWidth <= 768;
122114

123115
// Validate that both locations are within the same city
@@ -131,6 +123,7 @@ export default function Home() {
131123
"Cross-city travel is not supported. Please select locations within the same city (New York or San Francisco)"
132124
);
133125
setIsLoading(false);
126+
setIsPredicting(false);
134127
return;
135128
}
136129

@@ -151,6 +144,7 @@ export default function Home() {
151144
setAnimKey((k) => k + 1);
152145
}
153146
setIsLoading(false);
147+
setIsPredicting(false);
154148
return;
155149
}
156150
} catch (error) {
@@ -167,6 +161,7 @@ export default function Home() {
167161
setAnimKey((k) => k + 1);
168162
}
169163
setIsLoading(false);
164+
setIsPredicting(false);
170165
};
171166

172167
const resultRef = useRef<HTMLDivElement | null>(null);
@@ -359,6 +354,7 @@ export default function Home() {
359354
from={fromLocation}
360355
to={toLocation}
361356
animateKey={`${animKey}-${fromLocation?.id}-${toLocation?.id}`}
357+
isPredicting={isPredicting}
362358
/>
363359
</motion.div>
364360
</div>

0 commit comments

Comments
 (0)