diff --git a/backend/scripts/add_buildings.ts b/backend/scripts/add_buildings.ts index 07976aaa..a2bc4718 100644 --- a/backend/scripts/add_buildings.ts +++ b/backend/scripts/add_buildings.ts @@ -48,8 +48,6 @@ const formatBuilding = ({ area: getAreaType(area), latitude, longitude, - walkTime: 0, - driveTime: 0, }); const makeBuilding = async (apartmentWithId: ApartmentWithId) => { diff --git a/backend/src/app.ts b/backend/src/app.ts index 8d365cb5..2c5b1ac8 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -14,11 +14,13 @@ import { ApartmentWithId, CantFindApartmentForm, QuestionForm, + LocationTravelTimes, } from '@common/types/db-types'; // Import Firebase configuration and types import { auth } from 'firebase-admin'; import { Timestamp } from '@firebase/firestore-types'; import nodemailer from 'nodemailer'; +import axios from 'axios'; import { db, FieldValue, FieldPath } from './firebase-config'; import { Faq } from './firebase-config/types'; import authenticate from './auth'; @@ -39,6 +41,8 @@ const usersCollection = db.collection('users'); const pendingBuildingsCollection = db.collection('pendingBuildings'); const contactQuestionsCollection = db.collection('contactQuestions'); +const travelTimesCollection = db.collection('travelTimes'); + // Middleware setup const app: Express = express(); @@ -980,4 +984,260 @@ app.post('/api/add-contact-question', authenticate, async (req, res) => { } }); +const { REACT_APP_MAPS_API_KEY } = process.env; +const LANDMARKS = { + eng_quad: '42.4445,-76.4836', // Duffield Hall + ag_quad: '42.4489,-76.4780', // Mann Library + ho_plaza: '42.4468,-76.4851', // Ho Plaza +}; + +interface TravelTimeInput { + origin: string; // Can be either address or "latitude,longitude" +} + +/** + * getTravelTimes – Calculates travel times between an origin and multiple destinations using Google Distance Matrix API. + * + * @remarks + * Makes an HTTP request to the Google Distance Matrix API and processes the response to extract duration values. + * Times are converted from seconds to minutes before being returned. + * + * @param {string} origin - Starting location as either an address or coordinates in "lat,lng" format + * @param {string[]} destinations - Array of destination addresses to calculate times to + * @param {'walking' | 'driving'} mode - Mode of transportation to use for calculations + * @return {Promise} - Array of travel times in minutes to each destination + */ +async function getTravelTimes( + origin: string, + destinations: string[], + mode: 'walking' | 'driving' +): Promise { + const response = await axios.get( + `https://maps.googleapis.com/maps/api/distancematrix/json?origins=${encodeURIComponent( + origin + )}&destinations=${destinations + .map((dest) => encodeURIComponent(dest)) + .join('|')}&mode=${mode}&key=${REACT_APP_MAPS_API_KEY}` + ); + + return response.data.rows[0].elements.map( + (element: { duration: { value: number } }) => element.duration.value / 60 + ); +} + +/** + * Travel Times Calculator - Calculates walking and driving times from a given origin to Cornell landmarks. + * + * @remarks + * Uses Google Maps Distance Matrix API to calculate travel times to three landmarks: Engineering Quad, + * Agriculture Quad, and Ho Plaza. Returns both walking and driving durations in minutes. + * Origin can be either an address or coordinates in "latitude,longitude" format. + * + * @route POST /api/travel-times + * + * @input {string} req.body.origin - Starting point, either as address or "latitude,longitude" + * + * @status + * - 200: Successfully retrieved travel times + * - 400: Missing or invalid origin + * - 500: Server error or Google Maps API failure + */ +app.post('/api/calculate-travel-times', async (req, res) => { + try { + const { origin } = req.body as TravelTimeInput; + console.log('Origin:', origin); + + if (!origin) { + return res.status(400).json({ error: 'Origin is required' }); + } + + const destinations = Object.values(LANDMARKS); + console.log('Destinations array:', destinations); + + // Get walking and driving times using the helper function + const [walkingTimes, drivingTimes] = await Promise.all([ + getTravelTimes(origin, destinations, 'walking'), + getTravelTimes(origin, destinations, 'driving'), + ]); + + console.log('Raw walking times:', walkingTimes); + console.log('Raw driving times:', drivingTimes); + + const travelTimes: LocationTravelTimes = { + engQuadWalking: walkingTimes[0], + engQuadDriving: drivingTimes[0], + agQuadWalking: walkingTimes[1], + agQuadDriving: drivingTimes[1], + hoPlazaWalking: walkingTimes[2], + hoPlazaDriving: drivingTimes[2], + }; + + console.log('Final travel times:', travelTimes); + return res.status(200).json(travelTimes); + } catch (error) { + console.error('Error calculating travel times:', error); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * Test Travel Times Endpoint - Creates a travel times document for a specific building. + * + * @remarks + * Retrieves building coordinates from the buildings collection, calculates travel times to Cornell landmarks, + * and stores the results in the travelTimes collection. This endpoint is used for testing and populating + * travel time data for existing buildings. + * + * @param {string} buildingId - The ID of the building to calculate and store travel times for + * + * @return {Object} - Object containing success message, building ID, and calculated travel times + */ +app.post('/api/test-travel-times/:buildingId', async (req, res) => { + try { + const { buildingId } = req.params; + + // Get building data + const buildingDoc = await buildingsCollection.doc(buildingId).get(); + if (!buildingDoc.exists) { + return res.status(404).json({ error: 'Building not found' }); + } + + const buildingData = buildingDoc.data(); + if (!buildingData?.latitude || !buildingData?.longitude) { + return res.status(400).json({ error: 'Building missing coordinate data' }); + } + + // Calculate travel times using the main endpoint + const response = await axios.post(`/api/calculate-travel-times`, { + origin: `${buildingData.latitude},${buildingData.longitude}`, + }); + + // Store in Firebase + await travelTimesCollection.doc(buildingId).set(response.data); + + return res.status(200).json({ + message: 'Travel times calculated and stored successfully', + buildingId, + travelTimes: response.data, + }); + } catch (error) { + console.error('Error in test endpoint:', error); + return res.status(500).json({ error: 'Internal server error' }); + } +}); +/** + * Batch Travel Times Endpoint - Calculates and stores travel times for a batch of buildings. + * + * @remarks + * Processes a batch of buildings from the buildings collection, calculating travel times to Cornell landmarks + * for each one and storing the results in the travelTimes collection. This endpoint handles buildings in batches + * with rate limiting between requests. Supports pagination through the startAfter parameter. + * + * @param {number} batchSize - Number of buildings to process in this batch (defaults to 50) + * @param {string} [startAfter] - Optional ID of last processed building for pagination + * @return {Object} - Summary object containing: + * - Message indicating batch completion + * - Total number of buildings processed in this batch + * - Count of successful calculations + * - Arrays of successful building IDs and failed buildings with error details + */ + +// Helper function for rate limiting API requests +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +app.post('/api/batch-create-travel-times/:batchSize/:startAfter?', async (req, res) => { + try { + const batchSize = parseInt(req.params.batchSize, 10) || 50; + const { startAfter } = req.params; + + let query = buildingsCollection.limit(batchSize); + if (startAfter) { + const lastDoc = await buildingsCollection.doc(startAfter).get(); + query = query.startAfter(lastDoc); + } + + const buildingDocs = (await query.get()).docs; + const results = { + success: [] as string[], + failed: [] as { id: string; error: string }[], + }; + + const processPromises = buildingDocs.map(async (doc) => { + try { + await delay(100); // 100ms delay between requests + const buildingData = doc.data(); + if (!buildingData?.latitude || !buildingData?.longitude) { + results.failed.push({ + id: doc.id, + error: 'Missing coordinates', + }); + return; + } + + const response = await axios.post(`/api/calculate-travel-times`, { + origin: `${buildingData.latitude},${buildingData.longitude}`, + }); + + await travelTimesCollection.doc(doc.id).set(response.data); + results.success.push(doc.id); + } catch (error) { + results.failed.push({ + id: doc.id, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + }); + + await Promise.all(processPromises); + + res.status(200).json({ + message: 'Batch processing completed', + totalProcessed: buildingDocs.length, + successCount: results.success.length, + failureCount: results.failed.length, + hasMore: buildingDocs.length === batchSize, + lastProcessedId: buildingDocs[buildingDocs.length - 1]?.id, + results, + }); + } catch (error) { + console.error('Batch processing error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * Get Travel Times By Building ID - Retrieves pre-calculated travel times from the travel times collection. + * + * @remarks + * Looks up the travel times document for the given building ID and returns the stored walking and driving + * times to Cornell landmarks: Engineering Quad, Agriculture Quad, and Ho Plaza. + * + * @route GET /api/travel-times-by-id/:buildingId + * + * @input {string} req.params.buildingId - ID of the building to get travel times for + * + * @status + * - 200: Successfully retrieved travel times + * - 404: Building travel times not found + * - 500: Server error + */ +app.get('/api/travel-times-by-id/:buildingId', async (req, res) => { + try { + const { buildingId } = req.params; + + const travelTimeDoc = await travelTimesCollection.doc(buildingId).get(); + + if (!travelTimeDoc.exists) { + return res.status(404).json({ error: 'Travel times not found for this building' }); + } + + const travelTimes = travelTimeDoc.data() as LocationTravelTimes; + + return res.status(200).json(travelTimes); + } catch (error) { + console.error('Error retrieving travel times:', error); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + export default app; diff --git a/common/types/db-types.ts b/common/types/db-types.ts index 8fb75259..14d4e0b4 100644 --- a/common/types/db-types.ts +++ b/common/types/db-types.ts @@ -56,8 +56,15 @@ export type Apartment = { readonly area: 'COLLEGETOWN' | 'WEST' | 'NORTH' | 'DOWNTOWN' | 'OTHER'; readonly latitude: number; readonly longitude: number; - readonly walkTime: number; - readonly driveTime: number; +}; + +export type LocationTravelTimes = { + agQuadDriving: number; + agQuadWalking: number; + engQuadDriving: number; + engQuadWalking: number; + hoPlazaDriving: number; + hoPlazaWalking: number; }; export type ApartmentWithId = Apartment & Id; diff --git a/frontend/src/components/Apartment/MapInfo.tsx b/frontend/src/components/Apartment/MapInfo.tsx index ab3c525b..3e8c83d3 100644 --- a/frontend/src/components/Apartment/MapInfo.tsx +++ b/frontend/src/components/Apartment/MapInfo.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement, useRef } from 'react'; +import React, { ReactElement, useRef, useEffect } from 'react'; import { Box, Grid, IconButton, Typography, makeStyles } from '@material-ui/core'; import GoogleMapReact from 'google-map-react'; import aptIcon from '../../assets/location-pin.svg'; @@ -6,10 +6,10 @@ import schoolIcon from '../../assets/school-pin.svg'; import expandIcon from '../../assets/expand-button.svg'; import zoomInIcon from '../../assets/zoom-in-icon.png'; import zoomOutIcon from '../../assets/zoom-out-icon.png'; -import recenterIcon from '../../assets/recenter-icon.svg'; import blackPinIcon from '../../assets/ph_map-pin-fill.svg'; import { config } from 'dotenv'; import { Marker } from './Marker'; +import { LocationTravelTimes } from '../../../../common/types/db-types'; config(); @@ -17,18 +17,18 @@ export type BaseProps = { readonly address: string | null; readonly latitude?: number; readonly longitude?: number; - readonly walkTime?: number; - readonly driveTime?: number; + readonly travelTimes?: LocationTravelTimes; + isMobile: boolean; }; type MapInfoProps = BaseProps & { handleClick: () => void; - isMobile: boolean; + mapToggle: boolean; }; export type distanceProps = { location: string; - walkDistance: number; + walkDistance: number | undefined; }; const WalkDistanceInfo = ({ location, walkDistance }: distanceProps) => { @@ -141,57 +141,39 @@ const useStyles = makeStyles((theme) => ({ * - `longitude`: The longitude of the apartment location. * - `walkTime`: The walk time from the apartment to campus landmarks. */ -export default function MapInfo({ +function MapInfo({ address, latitude = 0, longitude = 0, - walkTime = 0, + travelTimes, handleClick, + mapToggle, isMobile, }: MapInfoProps): ReactElement { - const { outerMapDiv, innerMapDiv, mapExpandButton, recenterButton, zoomInButton, zoomOutButton } = - useStyles(); + const { outerMapDiv, innerMapDiv, mapExpandButton, zoomInButton, zoomOutButton } = useStyles(); const mapRef = useRef(null); const handleApiLoaded = ({ map, maps }: { map: google.maps.Map; maps: typeof google.maps }) => { mapRef.current = map; }; - const handleRecenter = () => { + useEffect(() => { if (mapRef.current) { mapRef.current.setCenter({ lat: latitude, lng: longitude }); mapRef.current.setZoom(16); } - }; + }, [mapToggle, latitude, longitude]); - // Function to handle zoom in/out of the map const handleZoom = (zoomChange: number) => { if (mapRef.current) { - const currentZoom = mapRef.current.getZoom() || 16; // Ensure there is a valid value for currentZoom + const currentZoom = mapRef.current.getZoom() || 16; const newZoom = currentZoom + zoomChange; if (newZoom > 11 && newZoom < 20) { - // Ensure the new zoom is within the allowed range mapRef.current.setZoom(newZoom); } } }; - const expandOrRecenter = (isMobile: boolean) => { - return isMobile ? ( - - {'recenter-icon'} - - ) : ( - - {'expand-icon'} - - ); - }; - return ( @@ -216,12 +198,6 @@ export default function MapInfo({ src={schoolIcon} altText="Engineering Quad icon" /> - - {expandOrRecenter(isMobile)} + + {'expand-icon'} + {
handleZoom(1)}> @@ -274,12 +256,23 @@ export default function MapInfo({ Distance from Campus - - - + + + ); } + +export default MapInfo; diff --git a/frontend/src/components/Apartment/MapModal.tsx b/frontend/src/components/Apartment/MapModal.tsx index 700fc48b..0b3ed270 100644 --- a/frontend/src/components/Apartment/MapModal.tsx +++ b/frontend/src/components/Apartment/MapModal.tsx @@ -26,24 +26,26 @@ import { BaseProps, distanceProps } from './MapInfo'; const useStyles = makeStyles((theme) => ({ paper: { - borderRadius: '13.895px', - maxWidth: '70%', - maxHeight: '94%', + borderRadius: ({ isMobile }: { isMobile: boolean }) => (isMobile ? '0px' : '13.895px'), + maxWidth: ({ isMobile }: { isMobile: boolean }) => (isMobile ? '100%' : '70%'), + maxHeight: ({ isMobile }: { isMobile: boolean }) => (isMobile ? '100%' : '94%'), + height: ({ isMobile }: { isMobile: boolean }) => (isMobile ? '100%' : 'auto'), + margin: ({ isMobile }: { isMobile: boolean }) => (isMobile ? '0' : undefined), overflow: 'hidden', }, outerMapDiv: { - height: '50vh', - width: '94%', - borderRadius: '12.764px', + height: ({ isMobile }: { isMobile: boolean }) => (isMobile ? '65vh' : '50vh'), + width: ({ isMobile }: { isMobile: boolean }) => (isMobile ? '100%' : '94%'), + borderRadius: '0px', overflow: 'hidden', outline: 'none', position: 'relative', - marginBottom: '30px', + marginBottom: ({ isMobile }: { isMobile: boolean }) => (isMobile ? '0' : '30px'), }, innerMapDiv: { height: '130%', width: '100%', - borderRadius: '12.764px', + borderRadius: '0px', overflow: 'hidden', outline: 'none', position: 'absolute', @@ -67,26 +69,53 @@ const useStyles = makeStyles((theme) => ({ width: '100%', overflow: 'auto', alignItems: 'center', - marginBottom: '30px', + marginBottom: ({ isMobile }: { isMobile: boolean }) => (isMobile ? '0' : '30px'), + padding: ({ isMobile }: { isMobile: boolean }) => (isMobile ? '0' : undefined), + }, + dataBox: { + width: '94%', + padding: ({ isMobile }: { isMobile: boolean }) => (isMobile ? '28px 0 0 20px' : 0), + display: 'flex', + justifyContent: 'space-between', + }, + aptNameTypography: { + fontWeight: 600, + fontSize: '24px', + }, + closeIcon: { + width: 'clamp(20px, 5vw, 26.9px)', + height: 'auto', + }, + controlButton: { + width: '100%', + height: 'auto', }, addressTypography: { fontWeight: 600, - fontSize: 'clamp(16px, 4vw, 20px)', - marginBottom: '16px', + fontSize: '20px', }, distanceTypography: { fontWeight: 600, - fontSize: 'clamp(14px, 3.5vw, 18px)', + fontSize: 'clamp(16px, 18px, 18px)', lineHeight: '28px', marginBottom: '10px', }, + travelIcon: { + width: 'clamp(16px, 4vw, 24px)', + height: 'auto', + }, + travelTimeTypography: { + fontSize: '16px', + }, + locationTypography: { + fontSize: '16px', + }, })); interface MapModalProps extends BaseProps { aptName: string; open: boolean; onClose: () => void; - setOpen: Dispatch>; } /** @@ -109,18 +138,18 @@ interface MapModalProps extends BaseProps { * - `walkTime`: The walk time from the apartment to campus landmarks (default: 0). * - `driveTime`: The drive time from the apartment to campus landmarks (default: 0). */ + const MapModal = ({ aptName, open, onClose, - setOpen, address, latitude = 0, longitude = 0, - walkTime = 0, - driveTime = 0, + travelTimes, + isMobile, }: MapModalProps) => { - const classes = useStyles(); + const classes = useStyles({ isMobile }); const theme = useTheme(); const mapRef = useRef(null); const isMediumScreen = useMediaQuery(theme.breakpoints.up('lg')); @@ -157,10 +186,10 @@ const MapModal = ({ }) => ( - {altText} + {altText} - + {distance} min @@ -169,9 +198,13 @@ const MapModal = ({ const DistanceInfo = ({ location, - walkDistance, - driveDistance, - }: distanceProps & { driveDistance: number }) => ( + walkTime, + driveTime, + }: { + location: string; + walkTime: number; + driveTime: number; + }) => ( - + {location} - + - + ); @@ -212,24 +245,23 @@ const MapModal = ({ }} maxWidth="md" fullWidth + fullScreen={isMobile} > - - - + + + {aptName} - setOpen(false)} style={{ padding: 0 }}> - {'close-icon'} + + {'close-icon'} @@ -259,12 +291,6 @@ const MapModal = ({ src={schoolIcon} altText="Engineering Quad icon" /> -
- {'recenter-icon'} + {'recenter-icon'} handleZoom(1)} > - {'zoom-in-icon'} + {'zoom-in-icon'} handleZoom(-1)} > - {'zoom-out-icon'} + {'zoom-out-icon'} {address} - {/* {isMediumScreen && ( - - Distance from Campus - - - - - )} */} Distance from Campus diff --git a/frontend/src/pages/ApartmentPage.tsx b/frontend/src/pages/ApartmentPage.tsx index 179c90de..26f0ec40 100644 --- a/frontend/src/pages/ApartmentPage.tsx +++ b/frontend/src/pages/ApartmentPage.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement, useState, useEffect } from 'react'; +import React, { ReactElement, useState, useEffect, useRef } from 'react'; import { IconButton, Button, @@ -26,6 +26,7 @@ import { Apartment, ApartmentWithId, DetailedRating, + LocationTravelTimes, } from '../../../common/types/db-types'; import Toast from '../components/utils/Toast'; import LinearProgress from '../components/utils/LinearProgress'; @@ -134,6 +135,7 @@ const ApartmentPage = ({ user, setUser }: Props): ReactElement => { const [buildings, setBuildings] = useState([]); const [aptData, setAptData] = useState([]); const [apt, setApt] = useState(undefined); + const [travelTimes, setTravelTimes] = useState(undefined); const { carouselPhotos, carouselStartIndex, @@ -154,6 +156,16 @@ const ApartmentPage = ({ user, setUser }: Props): ReactElement => { const saved = savedIcon; const unsaved = unsavedIcon; const [isSaved, setIsSaved] = useState(false); + const [mapToggle, setMapToggle] = useState(false); + + const dummyTravelTimes: LocationTravelTimes = { + agQuadDriving: -1, + agQuadWalking: -1, + engQuadDriving: -1, + engQuadWalking: -1, + hoPlazaDriving: -1, + hoPlazaWalking: -1, + }; // Set the number of results to show based on mobile or desktop view. useEffect(() => { @@ -196,6 +208,14 @@ const ApartmentPage = ({ user, setUser }: Props): ReactElement => { }); }, [aptId]); + // Fetch travel times data for the current apartment + useEffect(() => { + get(`/api/travel-times-by-id/${aptId}`, { + callback: setTravelTimes, + errorHandler: handlePageNotFound, + }); + }, [aptId]); + // Set the apartment data once it's fetched. useEffect(() => { setApt(aptData[0]); @@ -409,18 +429,22 @@ const ApartmentPage = ({ user, setUser }: Props): ReactElement => { setReviewOpen(true); }; + const handleMapModalClose = () => { + setMapOpen(false); + setMapToggle((prev) => !prev); + }; + const Modals = landlordData && apt && ( <> setMapOpen(false)} - setOpen={setMapOpen} + onClose={handleMapModalClose} address={apt!.address} longitude={apt!.longitude} latitude={apt!.latitude} - walkTime={apt!.walkTime} - driveTime={apt!.driveTime} + travelTimes={travelTimes} + isMobile={isMobile} /> { address={apt!.address} longitude={apt!.longitude} latitude={apt!.latitude} - walkTime={apt!.walkTime} - driveTime={apt!.driveTime} + travelTimes={travelTimes} handleClick={() => setMapOpen(true)} + mapToggle={mapToggle} isMobile={isMobile} />