From 2858d4d05951523cf43981b8211f00a172638453 Mon Sep 17 00:00:00 2001 From: anjelofan Date: Sat, 11 Apr 2026 20:38:34 +0800 Subject: [PATCH] feat: implement navigation --- apps/web/app/page.tsx | 102 +++++++++- apps/web/components/DirectionsRenderer.tsx | 62 ++++++ apps/web/components/HeadsUpDisplay.tsx | 6 + apps/web/components/NavigationHUD.tsx | 131 +++++++++++++ apps/web/components/PinDetailsCard.tsx | 90 ++++++++- apps/web/hooks/useNavigationRoute.ts | 211 +++++++++++++++++++++ apps/web/hooks/useUserLocation.ts | 133 +++++++++++++ apps/web/hooks/useWaypointState.ts | 26 +++ 8 files changed, 754 insertions(+), 7 deletions(-) create mode 100644 apps/web/components/DirectionsRenderer.tsx create mode 100644 apps/web/components/NavigationHUD.tsx create mode 100644 apps/web/hooks/useNavigationRoute.ts create mode 100644 apps/web/hooks/useUserLocation.ts diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 38736d6..99d852d 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -17,6 +17,10 @@ import { TargetLine } from "@/components/TargetLine"; import { Polyline } from "@/components/Polyline"; import { Polygon } from "@/components/Polygon"; import { Sidebar } from "@/components/Sidebar"; +import { NavigationHUD } from "@/components/NavigationHUD"; +import { DirectionsRenderer } from "@/components/DirectionsRenderer"; +import { useUserLocation } from "@/hooks/useUserLocation"; +import { useNavigationRoute } from "@/hooks/useNavigationRoute"; import { JEEPNEY_ROUTES, CAMPUS_ZONES, @@ -46,10 +50,13 @@ export default function Home() { const { mode, selectedPinId, + navigationPinId, selectPin, clearSelection, toggleMenu, toggleLock, + startNavigation, + stopNavigation, } = useWaypointState(); const [activeFilter, setActiveFilter] = useState("all"); const [searchQuery, setSearchQuery] = useState(""); @@ -89,9 +96,26 @@ export default function Home() { }); }, []); - const mockUserLocation = { lat: 14.6549, lng: 121.0645 }; + // Real user location from browser geolocation + const { + location: userLocation, + isLoading: isLocationLoading, + error: locationError, + } = useUserLocation(); + + // Fallback to mock location if real location not available yet + const effectiveUserLocation = userLocation ?? { lat: 14.6549, lng: 121.0645 }; const mockHeading = 45; + // Navigation route management + const { + route: navigationRoute, + error: routeError, + isLoading: isRouteLoading, + fetchRoute, + clearRoute, + } = useNavigationRoute(); + const { theme } = useTheme(); const [pendingPinCoords, setPendingPinCoords] = useState<{ @@ -112,6 +136,46 @@ export default function Home() { return pinsParsed.find((p) => p.id === selectedPinId); }, [pinsParsed, selectedPinId]); + // Find the navigation destination pin + const navigationDestination = useMemo(() => { + if (!navigationPinId) return null; + return pinsParsed.find((p) => p.id === navigationPinId); + }, [navigationPinId, pinsParsed]); + + // Fetch route when navigation starts (only when navigationPinId changes, not on location updates) + useEffect(() => { + + + if ( + navigationPinId && + navigationDestination && + effectiveUserLocation + ) { + fetchRoute( + { + latitude: effectiveUserLocation.lat, + longitude: effectiveUserLocation.lng, + }, + { + latitude: navigationDestination.latitude, + longitude: navigationDestination.longitude, + }, + ); + } + // Only depend on navigationPinId to avoid re-fetching on every location update + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [navigationPinId]); + + // Handle navigation click + const handleNavigateClick = useCallback(() => { + if (mode === "NAVIGATING" && navigationPinId) { + stopNavigation(); + clearRoute(); + } else if (selectedPinId) { + startNavigation(selectedPinId); + } + }, [mode, navigationPinId, selectedPinId, startNavigation, stopNavigation, clearRoute]); + useEffect(() => { if (!isAddingPin) return; const handleMouseMove = (e: MouseEvent) => { @@ -207,18 +271,31 @@ export default function Home() { ); })} - + - {activePinObj && ( + {activePinObj && mode !== "NAVIGATING" && ( )} + {/* Navigation Route Polyline */} + {mode === "NAVIGATING" && navigationRoute && ( + ({ + lat: c.latitude, + lng: c.longitude, + }))} + color="#00b0ff" + weight={5} + visible={true} + /> + )} + {CAMPUS_ZONES.map((zone) => { if (!activeZoneCategories.includes(zone.categoryId)) return null; @@ -266,10 +343,21 @@ export default function Home() { onToggleRoute={handleToggleRoute} activeZoneCategories={activeZoneCategories} onToggleZoneCategory={handleToggleZoneCategory} - userLocation={mockUserLocation} - hideControls={!!selectedPinId} + userLocation={effectiveUserLocation} + hideControls={!!selectedPinId || mode === "NAVIGATING"} /> + {/* Navigation HUD - shown when navigating */} + {mode === "NAVIGATING" && navigationRoute && ( + + )} + {/* TARGETING CROSSHAIR (Only visible when armed) */} {isAddingPin && (
{ clearSelection(); diff --git a/apps/web/components/DirectionsRenderer.tsx b/apps/web/components/DirectionsRenderer.tsx new file mode 100644 index 0000000..a621814 --- /dev/null +++ b/apps/web/components/DirectionsRenderer.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useMap, useApiIsLoaded } from "@vis.gl/react-google-maps"; + +interface DirectionsRendererProps { + path: { lat: number; lng: number }[]; + color?: string; + weight?: number; + visible?: boolean; +} + +export function DirectionsRenderer({ + path, + color = "#00b0ff", + weight = 5, + visible = true, +}: DirectionsRendererProps) { + const map = useMap(); + const isLoaded = useApiIsLoaded(); + const polylineRef = useRef(null); + + useEffect(() => { + if (!map || !isLoaded || !window.google || !visible || path.length === 0) { + // Clean up polyline if it exists + if (polylineRef.current) { + polylineRef.current.setMap(null); + polylineRef.current = null; + } + return; + } + + if (!polylineRef.current) { + polylineRef.current = new window.google.maps.Polyline({ + path, + strokeColor: color, + strokeWeight: weight, + strokeOpacity: 0.9, + geodesic: true, + map, + }); + } else { + polylineRef.current.setOptions({ + path, + strokeColor: color, + strokeWeight: weight, + }); + } + }, [map, isLoaded, path, color, weight, visible]); + + // Cleanup on unmount or when visibility changes + useEffect(() => { + return () => { + if (polylineRef.current) { + polylineRef.current.setMap(null); + polylineRef.current = null; + } + }; + }, []); + + return null; +} diff --git a/apps/web/components/HeadsUpDisplay.tsx b/apps/web/components/HeadsUpDisplay.tsx index fc6ac9b..be2e6fd 100644 --- a/apps/web/components/HeadsUpDisplay.tsx +++ b/apps/web/components/HeadsUpDisplay.tsx @@ -9,6 +9,8 @@ interface HUDProps { selectedPinId: string | null; onLockClick: () => void; isLocked: boolean; + isNavigating?: boolean; + onNavigateClick: () => void; onClearSelection?: () => void; onAddPinClick?: () => void; } @@ -17,6 +19,8 @@ export function HeadsUpDisplay({ selectedPinId, onLockClick, isLocked, + isNavigating = false, + onNavigateClick, onClearSelection, onAddPinClick, }: HUDProps) { @@ -58,7 +62,9 @@ export function HeadsUpDisplay({ setIsExpanded(true)} /> diff --git a/apps/web/components/NavigationHUD.tsx b/apps/web/components/NavigationHUD.tsx new file mode 100644 index 0000000..cf16ec5 --- /dev/null +++ b/apps/web/components/NavigationHUD.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { clsxm } from "@repo/ui/clsxm"; + +interface NavigationHUDProps { + distanceMeters: number; + durationSeconds: number; + isLoading?: boolean; + error?: string | null; + onCancel: () => void; +} + +function formatDistance(meters: number): string { + if (meters < 1000) { + return `${Math.round(meters)} m`; + } + const km = meters / 1000; + return `${km.toFixed(1)} km`; +} + +function formatDuration(seconds: number): string { + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return `${minutes} min`; + } + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return `${hours}h ${remainingMinutes}min`; +} + +export function NavigationHUD({ + distanceMeters, + durationSeconds, + isLoading, + error, + onCancel, +}: NavigationHUDProps) { + return ( +
+
+ {isLoading ? ( +
+
+ + Calculating route... + +
+ ) : error ? ( +
+ + {error} + + +
+ ) : ( + <> +
+ {/* Distance */} +
+ + Distance + + + {formatDistance(distanceMeters)} + +
+ + {/* Divider */} +
+ + {/* Duration */} +
+ + Walking Time + + + {formatDuration(durationSeconds)} + +
+
+ + {/* Walking indicator */} +
+ + + + + + + + Follow the blue route on map + +
+ + {/* Cancel button */} + + + )} +
+
+ ); +} diff --git a/apps/web/components/PinDetailsCard.tsx b/apps/web/components/PinDetailsCard.tsx index 59f92de..6480c3e 100644 --- a/apps/web/components/PinDetailsCard.tsx +++ b/apps/web/components/PinDetailsCard.tsx @@ -7,7 +7,9 @@ import { clsxm } from "@repo/ui/clsxm"; interface PinDetailsCardProps { pinId: string; isLocked: boolean; + isNavigating?: boolean; onLockClick: () => void; + onNavigateClick: () => void; onClose?: () => void; onExpand: () => void; } @@ -15,7 +17,9 @@ interface PinDetailsCardProps { export function PinDetailsCard({ pinId, isLocked, + isNavigating = false, onLockClick, + onNavigateClick, onClose, onExpand, }: PinDetailsCardProps) { @@ -108,7 +112,91 @@ export function PinDetailsCard({ )} onClick={onLockClick} > - {isLocked ? "NAVIGATING..." : "NAVIGATE"} + {isLocked ? ( + + + + + + TRACKING + + ) : ( + + + + + + TRACK + + )} + + + {/* NAVIGATE BUTTON - Shows route */} +
diff --git a/apps/web/hooks/useNavigationRoute.ts b/apps/web/hooks/useNavigationRoute.ts new file mode 100644 index 0000000..005c985 --- /dev/null +++ b/apps/web/hooks/useNavigationRoute.ts @@ -0,0 +1,211 @@ +"use client"; + +import { useState, useCallback, useRef } from "react"; + +export interface Coordinate { + latitude: number; + longitude: number; +} + +export interface RouteInfo { + coordinates: Coordinate[]; + distanceMeters: number; + durationSeconds: number; + encodedPath: string; +} + +interface UseNavigationRouteReturn { + route: RouteInfo | null; + error: string | null; + isLoading: boolean; + fetchRoute: (origin: Coordinate, destination: Coordinate) => Promise; + clearRoute: () => void; +} + +// Standard polyline decoder algorithm used by Google +const decodePolyline = (encoded: string): Coordinate[] => { + const poly: Coordinate[] = []; + let index = 0, + len = encoded.length; + let lat = 0, + lng = 0; + + while (index < len) { + let b, + shift = 0, + result = 0; + do { + b = encoded.charCodeAt(index++) - 63; + result |= (b & 0x1f) << shift; + shift += 5; + } while (b >= 0x20); + const dlat = result & 1 ? ~(result >> 1) : result >> 1; + lat += dlat; + + shift = 0; + result = 0; + do { + b = encoded.charCodeAt(index++) - 63; + result |= (b & 0x1f) << shift; + shift += 5; + } while (b >= 0x20); + const dlng = result & 1 ? ~(result >> 1) : result >> 1; + lng += dlng; + + poly.push({ + latitude: lat / 1e5, + longitude: lng / 1e5, + }); + } + return poly; +}; + +export function useNavigationRoute(): UseNavigationRouteReturn { + const [route, setRoute] = useState(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + // Track the current request to prevent duplicate requests + const currentRequestRef = useRef<{ + origin: Coordinate; + destination: Coordinate; + abortController: AbortController; + } | null>(null); + + const fetchRoute = useCallback( + async (origin: Coordinate, destination: Coordinate) => { + const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY; + + + if (!apiKey) { + setError("Google Maps API key not configured"); + return; + } + + // Check if we already have the same route cached + if ( + currentRequestRef.current && + currentRequestRef.current.origin.latitude === origin.latitude && + currentRequestRef.current.origin.longitude === origin.longitude && + currentRequestRef.current.destination.latitude === + destination.latitude && + currentRequestRef.current.destination.longitude === destination.longitude + ) { + // Same request already in progress, don't make another + return; + } + + // Cancel any existing request + if (currentRequestRef.current) { + currentRequestRef.current.abortController.abort(); + } + + // Create new abort controller + const abortController = new AbortController(); + currentRequestRef.current = { + origin, + destination, + abortController, + }; + + setIsLoading(true); + setError(null); + + try { + const response = await fetch( + "https://routes.googleapis.com/directions/v2:computeRoutes", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Goog-Api-Key": apiKey, + "X-Goog-FieldMask": + "routes.duration,routes.distanceMeters,routes.polyline.encodedPolyline", + }, + body: JSON.stringify({ + origin: { + location: { + latLng: { + latitude: origin.latitude, + longitude: origin.longitude, + }, + }, + }, + destination: { + location: { + latLng: { + latitude: destination.latitude, + longitude: destination.longitude, + }, + }, + }, + travelMode: "WALK", + }), + signal: abortController.signal, + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("[Navigation] HTTP Error Response:", errorText); + throw new Error(`HTTP error: ${response.status} - ${errorText}`); + } + + const data = await response.json(); + + + if (data.routes && data.routes.length > 0) { + const routeData = data.routes[0]; + + // Decode the encoded polyline string into coordinates + const decodedCoordinates = decodePolyline( + routeData.polyline.encodedPolyline, + ); + + setRoute({ + coordinates: decodedCoordinates, + distanceMeters: routeData.distanceMeters, + durationSeconds: parseInt(routeData.duration.replace("s", ""), 10), + encodedPath: routeData.polyline.encodedPolyline, + }); + + // Clear current request reference since we succeeded + currentRequestRef.current = null; + } else { + setError("No route found"); + setRoute(null); + } + } catch (err) { + // Ignore abort errors + if (err instanceof Error && err.name === "AbortError") { + return; + } + console.error("[Navigation] Error fetching route:", err); + setError(err instanceof Error ? err.message : "Failed to fetch route"); + setRoute(null); + } finally { + setIsLoading(false); + } + }, + [], + ); + + const clearRoute = useCallback(() => { + // Cancel any in-progress request + if (currentRequestRef.current) { + currentRequestRef.current.abortController.abort(); + currentRequestRef.current = null; + } + setRoute(null); + setError(null); + setIsLoading(false); + }, []); + + return { + route, + error, + isLoading, + fetchRoute, + clearRoute, + }; +} diff --git a/apps/web/hooks/useUserLocation.ts b/apps/web/hooks/useUserLocation.ts new file mode 100644 index 0000000..f5aace3 --- /dev/null +++ b/apps/web/hooks/useUserLocation.ts @@ -0,0 +1,133 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; + +export interface UserLocation { + lat: number; + lng: number; + heading?: number; + accuracy?: number; +} + +interface UseUserLocationOptions { + enableHighAccuracy?: boolean; + timeout?: number; + maximumAge?: number; +} + +interface UseUserLocationReturn { + location: UserLocation | null; + error: string | null; + isLoading: boolean; + isPermissionDenied: boolean; + refreshLocation: () => void; +} + +export function useUserLocation( + options: UseUserLocationOptions = {}, +): UseUserLocationReturn { + const { + enableHighAccuracy = true, + timeout = 10000, + maximumAge = 5000, + } = options; + + const [location, setLocation] = useState(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isPermissionDenied, setIsPermissionDenied] = useState(false); + + const watchIdRef = useRef(null); + + const handleSuccess = useCallback((position: GeolocationPosition) => { + setLocation({ + lat: position.coords.latitude, + lng: position.coords.longitude, + heading: position.coords.heading ?? undefined, + accuracy: position.coords.accuracy, + }); + setError(null); + setIsLoading(false); + }, []); + + const handleError = useCallback((err: GeolocationPositionError) => { + switch (err.code) { + case err.PERMISSION_DENIED: + setError("Location permission denied"); + setIsPermissionDenied(true); + break; + case err.POSITION_UNAVAILABLE: + setError("Location information unavailable"); + break; + case err.TIMEOUT: + setError("Location request timed out"); + break; + default: + setError("Unknown location error"); + } + setIsLoading(false); + }, []); + + const getLocation = useCallback(() => { + if (!navigator.geolocation) { + setError("Geolocation not supported by browser"); + setIsLoading(false); + return; + } + + setIsLoading(true); + setError(null); + + // Use getCurrentPosition for initial fetch + navigator.geolocation.getCurrentPosition( + handleSuccess, + handleError, + { + enableHighAccuracy, + timeout, + maximumAge, + }, + ); + }, [enableHighAccuracy, timeout, maximumAge, handleSuccess, handleError]); + + const refreshLocation = useCallback(() => { + // Clear any existing watch + if (watchIdRef.current !== null) { + navigator.geolocation.clearWatch(watchIdRef.current); + watchIdRef.current = null; + } + getLocation(); + }, [getLocation]); + + useEffect(() => { + getLocation(); + + // Set up continuous watching for updates + if (navigator.geolocation) { + watchIdRef.current = navigator.geolocation.watchPosition( + handleSuccess, + handleError, + { + enableHighAccuracy, + timeout, + maximumAge: 2000, // More frequent updates for navigation + }, + ); + } + + return () => { + if (watchIdRef.current !== null) { + navigator.geolocation.clearWatch(watchIdRef.current); + watchIdRef.current = null; + } + }; + }, [getLocation, handleSuccess, handleError, enableHighAccuracy, timeout, maximumAge]); + + return { + location, + error, + isLoading, + isPermissionDenied, + refreshLocation, + }; +} diff --git a/apps/web/hooks/useWaypointState.ts b/apps/web/hooks/useWaypointState.ts index 1c5cf2a..5b9e3a7 100644 --- a/apps/web/hooks/useWaypointState.ts +++ b/apps/web/hooks/useWaypointState.ts @@ -5,11 +5,13 @@ export type AppMode = | "SELECTED" // Pin clicked (Simple HUD) | "LOCKED" // Double-clicked (Tracking Line) | "EXPANDED" // "View More" clicked (Full details) + | "NAVIGATING" // Navigation mode active (route displayed) | "MENU"; // Sidebar open export function useWaypointState() { const [mode, setMode] = useState("IDLE"); const [selectedPinId, setSelectedPinId] = useState(null); + const [navigationPinId, setNavigationPinId] = useState(null); // select a pin const selectPin = useCallback( @@ -36,6 +38,7 @@ export function useWaypointState() { // otherwise, fully reset setMode("IDLE"); setSelectedPinId(null); + setNavigationPinId(null); } }, [mode]); @@ -59,13 +62,36 @@ export function useWaypointState() { setMode((prev) => (prev === "MENU" ? "IDLE" : "MENU")); }, []); + // start navigation to a pin + const startNavigation = useCallback( + (pinId: string) => { + setNavigationPinId(pinId); + setMode("NAVIGATING"); + }, + [], + ); + + // stop navigation + const stopNavigation = useCallback(() => { + setNavigationPinId(null); + // Don't fully reset - stay in selected mode with the pin still selected + if (selectedPinId) { + setMode("SELECTED"); + } else { + setMode("IDLE"); + } + }, [selectedPinId]); + return { mode, selectedPinId, + navigationPinId, selectPin, clearSelection, expandDetails, toggleMenu, toggleLock, + startNavigation, + stopNavigation, }; }