diff --git a/README.md b/README.md index e36ce80..a9543ff 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,9 @@ See `.env.example` for an example of the required keys and values. ### Geocoding -- `PRIVATE_OBA_GEOCODER_API_KEY` - string: Your Geocoder service's API key. Leave this blank if you don't have one. +- `PRIVATE_OBA_GEOCODER_API_KEY` - string: Your Geocoder service's API key. Ensure that the Geocoder and Places API permissions are enabled. Leave this blank if you don't have one. - `PRIVATE_OBA_GEOCODER_PROVIDER` - string: Your Geocoder service. We currently only support the Google Places SDK (value: "google"). - ### Trip Planner - `PUBLIC_OTP_SERVER_URL` - string: Your OpenTripPlanner 1.x-compatible trip planner server URL. Leave this blank if you don't have one. diff --git a/src/components/map/MapView.svelte b/src/components/map/MapView.svelte index 37a698b..a45e41e 100644 --- a/src/components/map/MapView.svelte +++ b/src/components/map/MapView.svelte @@ -12,6 +12,7 @@ import { faBus } from '@fortawesome/free-solid-svg-icons'; import { RouteType, routePriorities, prioritizedRouteTypeForDisplay } from '$config/routeConfig'; + import { isMapLoaded } from '$src/stores/mapStore'; export let selectedTrip = null; export let selectedRoute = null; @@ -20,6 +21,8 @@ export let showAllStops = true; export let stop = null; export let mapProvider = null; + let isTripPlanMoodActive = false; + let selectedStopID = null; const dispatch = createEventDispatcher(); @@ -163,6 +166,17 @@ allStops.forEach((s) => addMarker(s)); } + // TODO: prevent fetch stops-for-location if the trip planner mode is on - we should do this after merge. + $: { + if (isTripPlanMoodActive) { + clearAllMarkers(); + } else { + if (!selectedRoute || !showRoute) { + allStops.forEach((s) => addMarker(s)); + } + } + } + function addMarker(s, routeReference) { if (!mapInstance) { console.error('Map not initialized yet'); @@ -216,8 +230,15 @@ onMount(async () => { await initMap(); + isMapLoaded.set(true); if (browser) { const darkMode = document.documentElement.classList.contains('dark'); + window.addEventListener('planTripTabClicked', () => { + isTripPlanMoodActive = true; + }); + window.addEventListener('tabSwitched', () => { + isTripPlanMoodActive = false; + }); const event = new CustomEvent('themeChange', { detail: { darkMode } }); window.dispatchEvent(event); } diff --git a/src/components/search/SearchPane.svelte b/src/components/search/SearchPane.svelte index 7371314..5917950 100644 --- a/src/components/search/SearchPane.svelte +++ b/src/components/search/SearchPane.svelte @@ -9,10 +9,14 @@ import { clearVehicleMarkersMap, fetchAndUpdateVehicles } from '$lib/vehicleUtils'; import { calculateMidpoint } from '$lib/mathUtils'; import { Tabs, TabItem } from 'flowbite-svelte'; + import { PUBLIC_OTP_SERVER_URL } from '$env/static/public'; + import TripPlan from '$components/trip-planner/TripPlan.svelte'; + import { isMapLoaded } from '$src/stores/mapStore'; const dispatch = createEventDispatcher(); export let cssClasses = ''; + export let mapProvider = null; let routes = null; let stops = null; @@ -20,33 +24,26 @@ let query = null; let polylines = []; let currentIntervalId = null; - - export let mapProvider = null; + let mapLoaded = false; function handleLocationClick(location) { clearResults(); - const lat = location.geometry.location.lat; const lng = location.geometry.location.lng; - mapProvider.panTo(lat, lng); mapProvider.setZoom(20); - dispatch('locationSelected', { location }); } function handleStopClick(stop) { clearResults(); - mapProvider.panTo(stop.lat, stop.lon); mapProvider.setZoom(20); - dispatch('stopSelected', { stop }); } async function handleRouteClick(route) { clearResults(); - const response = await fetch(`/api/oba/stops-for-route/${route.id}`); const stopsForRoute = await response.json(); const stops = stopsForRoute.data.references.stops; @@ -55,7 +52,6 @@ for (const polylineData of polylinesData) { const shape = polylineData.points; let polyline; - polyline = mapProvider.createPolyline(shape); polylines.push(polyline); } @@ -63,10 +59,8 @@ await showStopsOnRoute(stops); currentIntervalId = await fetchAndUpdateVehicles(route.id, mapProvider); const midpoint = calculateMidpoint(stopsForRoute.data.references.stops); - mapProvider.panTo(midpoint.lat, midpoint.lng); mapProvider.setZoom(12); - dispatch('routeSelected', { route, stopsForRoute, stops, polylines, currentIntervalId }); } @@ -101,7 +95,25 @@ clearInterval(currentIntervalId); } + function handleTripPlan(event) { + dispatch('tripPlanned', event.detail); + } + + function handlePlanTripTabClick() { + const event = new CustomEvent('planTripTabClicked'); + window.dispatchEvent(event); + } + + function handleTabSwitch() { + const event = new CustomEvent('tabSwitched'); + window.dispatchEvent(event); + } + onMount(() => { + isMapLoaded.subscribe((value) => { + mapLoaded = value; + }); + window.addEventListener('routeSelectedFromModal', (event) => { handleRouteClick(event.detail.route); }); @@ -109,8 +121,8 @@
- - + + {#if query} @@ -158,7 +170,7 @@
- - plan a trip UI goes here! - + + {#if PUBLIC_OTP_SERVER_URL} + + + + {/if}
diff --git a/src/components/trip-planner/ItineraryDetails.svelte b/src/components/trip-planner/ItineraryDetails.svelte new file mode 100644 index 0000000..8c15685 --- /dev/null +++ b/src/components/trip-planner/ItineraryDetails.svelte @@ -0,0 +1,27 @@ + + +
+
+

Duration

+

{Math.round(itinerary.duration / 60)} min

+
+
+

Start Time

+

{formatTime(itinerary.startTime)}

+
+
+

End Time

+

{formatTime(itinerary.endTime)}

+
+
+
+ {#each itinerary.legs as leg, index} + + {/each} +
diff --git a/src/components/trip-planner/ItineraryTab.svelte b/src/components/trip-planner/ItineraryTab.svelte new file mode 100644 index 0000000..30272af --- /dev/null +++ b/src/components/trip-planner/ItineraryTab.svelte @@ -0,0 +1,14 @@ + + + diff --git a/src/components/trip-planner/LegDetails.svelte b/src/components/trip-planner/LegDetails.svelte new file mode 100644 index 0000000..48917cb --- /dev/null +++ b/src/components/trip-planner/LegDetails.svelte @@ -0,0 +1,134 @@ + + +
+
+
+ {#if icon} + + {/if} +
+ +
+
+
{leg.from.name}
+
+ +
+
+ + Start: +
+ {formatTime(leg.startTime).slice(0, -3)} + {formatTime(leg.startTime).slice(-2)} +
+
+
+ + End: +
+ {formatTime(leg.endTime).slice(0, -3)} + {formatTime(leg.endTime).slice(-2)} +
+
+
+ +
+
+ + {leg.to.name} +
+
+ + Distance: {Math.round(leg.distance)} meters +
+
+ + Duration: {Math.round(leg.duration / 60)} minutes +
+
+ + {#if isWalking} + + + {#if expandedSteps[index]} +
+ {#each leg.steps as step} +
+
{step.relativeDirection} on {step.streetName}
+
+ + Distance: {Math.round(step.distance)} meters +
+
+ + {step.absoluteDirection} +
+
+ {/each} +
+ {/if} + {/if} +
+
diff --git a/src/components/trip-planner/TripPlan.svelte b/src/components/trip-planner/TripPlan.svelte new file mode 100644 index 0000000..af75512 --- /dev/null +++ b/src/components/trip-planner/TripPlan.svelte @@ -0,0 +1,211 @@ + + +
+ handleSearchInput(query, true)} + onClear={() => clearInput(true)} + onSelect={(location) => selectLocation(location, true)} + /> + + handleSearchInput(query, false)} + onClear={() => clearInput(false)} + onSelect={(location) => selectLocation(location, false)} + /> + + +
diff --git a/src/components/trip-planner/TripPlanModal.svelte b/src/components/trip-planner/TripPlanModal.svelte new file mode 100644 index 0000000..47d4d0c --- /dev/null +++ b/src/components/trip-planner/TripPlanModal.svelte @@ -0,0 +1,88 @@ + + + + {#if loading} + + {/if} + + {#if itineraries.length > 0} +
+ + {#each itineraries as _, index} + + {/each} +
+ +
+ {#if itineraries[activeTab]} + + {/if} +
+ {:else} +
+ No itineraries found +
+ {/if} +
diff --git a/src/components/trip-planner/TripPlanSearchField.svelte b/src/components/trip-planner/TripPlanSearchField.svelte new file mode 100644 index 0000000..602585c --- /dev/null +++ b/src/components/trip-planner/TripPlanSearchField.svelte @@ -0,0 +1,81 @@ + + +
+ +
+ + {#if place} + + {/if} +
+ {#if isLoading} +

+ Loading... +

+ {:else if results.length > 0} + + {/if} +
diff --git a/src/components/trip-planner/tripPlanPinMarker.svelte b/src/components/trip-planner/tripPlanPinMarker.svelte new file mode 100644 index 0000000..d3d0706 --- /dev/null +++ b/src/components/trip-planner/tripPlanPinMarker.svelte @@ -0,0 +1,31 @@ + + +
+
+ {text} +
+ + + +
diff --git a/src/lib/Provider/GoogleMapProvider.js b/src/lib/Provider/GoogleMapProvider.js index 90ccfb2..e4feffd 100644 --- a/src/lib/Provider/GoogleMapProvider.js +++ b/src/lib/Provider/GoogleMapProvider.js @@ -5,6 +5,7 @@ import { COLORS } from '$lib/colors'; import PopupContent from '$components/map/PopupContent.svelte'; import VehiclePopupContent from '$components/map/VehiclePopupContent.svelte'; import { createVehicleIconSvg } from '$lib/MapHelpers/generateVehicleIcon'; +import TripPlanPinMarker from '$components/trip-planner/tripPlanPinMarker.svelte'; export default class GoogleMapProvider { constructor(apiKey) { this.apiKey = apiKey; @@ -154,6 +155,10 @@ export default class GoogleMapProvider { unHighlightMarker(stopId) { const marker = this.markersMap.get(stopId); + + if (!marker) { + return; + } marker.$set({ isHighlighted: false }); } @@ -164,6 +169,57 @@ export default class GoogleMapProvider { this.stopMarkers = []; } + addPinMarker(position, text) { + const container = document.createElement('div'); + document.body.appendChild(container); + + new TripPlanPinMarker({ + target: container, + props: { + text: text + } + }); + + const overlay = new google.maps.OverlayView(); + + overlay.onAdd = function () { + this.getPanes().overlayMouseTarget.appendChild(container); + }; + + overlay.draw = function () { + const projection = this.getProjection(); + const pos = projection.fromLatLngToDivPixel( + new google.maps.LatLng(position.lat, position.lng) + ); + container.style.left = `${pos.x - 16}px`; + container.style.top = `${pos.y - 50}px`; + container.style.position = 'absolute'; + container.style.zIndex = '1000'; + }; + + overlay.onRemove = function () { + container.parentNode.removeChild(container); + }; + + overlay.setMap(this.map); + + return { overlay, element: container }; + } + + removePinMarker(marker) { + if (!marker) { + return; + } + + if (marker.overlay) { + marker.overlay.setMap(null); + } + + if (marker.element && marker.element.parentNode) { + marker.element.parentNode.removeChild(marker.element); + } + } + addVehicleMarker(vehicle, activeTrip) { if (!this.map) return null; diff --git a/src/lib/Provider/OpenStreetMapProvider.js b/src/lib/Provider/OpenStreetMapProvider.js index a7a9ea6..f6de311 100644 --- a/src/lib/Provider/OpenStreetMapProvider.js +++ b/src/lib/Provider/OpenStreetMapProvider.js @@ -7,6 +7,7 @@ import { COLORS } from '$lib/colors'; import PopupContent from '$components/map/PopupContent.svelte'; import { createVehicleIconSvg } from '$lib/MapHelpers/generateVehicleIcon'; import VehiclePopupContent from '$components/map/VehiclePopupContent.svelte'; +import TripPlanPinMarker from '$components/trip-planner/tripPlanPinMarker.svelte'; export default class OpenStreetMapProvider { constructor() { @@ -89,6 +90,38 @@ export default class OpenStreetMapProvider { return marker; } + addPinMarker(position, text) { + if (!this.map) return null; + + const container = document.createElement('div'); + + new TripPlanPinMarker({ + target: container, + props: { + text: text + } + }); + + const customIcon = this.L.divIcon({ + html: container, + className: '', + iconSize: [32, 50], + iconAnchor: [16, 50] + }); + + const marker = this.L.marker([position.lat, position.lng], { icon: customIcon }).addTo( + this.map + ); + + return marker; + } + + removePinMarker(marker) { + if (marker) { + marker.remove(); + } + } + highlightMarker(stopId) { const marker = this.markersMap.get(stopId); if (!marker) return; @@ -312,7 +345,7 @@ export default class OpenStreetMapProvider { }).addTo(this.map); } - createPolyline(points) { + createPolyline(points, options = { withArrow: true }) { if (!browser || !this.map) return null; const decodedPolyline = PolylineUtil.decode(points); @@ -322,11 +355,13 @@ export default class OpenStreetMapProvider { } const polyline = new this.L.Polyline(decodedPolyline, { - color: COLORS.POLYLINE, - weight: 4, - opacity: 1.0 + color: options.color || COLORS.POLYLINE, + weight: options.weight || 4, + opacity: options.opacity || 1 }).addTo(this.map); + if (!options.withArrow) return polyline; + const arrowDecorator = this.L.polylineDecorator(polyline, { patterns: [ { diff --git a/src/lib/formatters.js b/src/lib/formatters.js index c7e57bb..f11cfde 100644 --- a/src/lib/formatters.js +++ b/src/lib/formatters.js @@ -34,3 +34,11 @@ export function formatLastUpdated(timestamp, translations) { } return `${seconds} ${translations.sec} ${translations.ago}`; } + +export function formatTime(dateString) { + return new Date(dateString).toLocaleTimeString([], { + hour: 'numeric', + minute: '2-digit', + hour12: true + }); +} diff --git a/src/lib/geocoder.js b/src/lib/geocoder.js index ae3f072..8493da9 100644 --- a/src/lib/geocoder.js +++ b/src/lib/geocoder.js @@ -10,3 +10,17 @@ export async function googleGeocode({ apiKey, query }) { return null; } } + +export async function googlePlacesAutocomplete({ apiKey, input }) { + const response = await fetch(`https://places.googleapis.com/v1/places:autocomplete`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': apiKey + }, + body: JSON.stringify({ input }) + }); + const data = await response.json(); + + return data.suggestions; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 18ce417..c468ad6 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -8,6 +8,8 @@ import AlertsModal from '$components/navigation/AlertsModal.svelte'; import { onMount } from 'svelte'; import StopModal from '$components/stops/StopModal.svelte'; + import TripPlanModal from '$components/trip-planner/TripPlanModal.svelte'; + import { browser } from '$app/environment'; let stop; let selectedTrip = null; @@ -16,6 +18,7 @@ let showRouteMap = false; let showAllStops = false; let showAllRoutesModal = false; + let showTripPlanModal = false; let showRouteModal; let mapProvider = null; let currentIntervalId = null; @@ -24,6 +27,11 @@ let polylines = []; let stops = []; + let tripItineraries = []; + let loadingItineraries = false; + let fromMarker = null; + let toMarker = null; + $: { if (showRouteModal && showAllRoutesModal) { showAllRoutesModal = false; @@ -76,6 +84,7 @@ showAllRoutesModal = false; mapProvider.unHighlightMarker(currentHighlightedStopId); currentHighlightedStopId = null; + showTripPlanModal = false; } function tripSelected(event) { @@ -138,8 +147,30 @@ } } + function handleTripPlan(event) { + const tripData = event.detail.data; + fromMarker = event.detail.fromMarker; + toMarker = event.detail.toMarker; + tripItineraries = tripData.plan?.itineraries; + if (!tripItineraries) { + console.error('No itineraries found', 404); + } + showTripPlanModal = true; + } + onMount(() => { loadAlerts(); + + // close the trip plan modal when the tab is switched + if (browser) { + window.addEventListener('tabSwitched', () => { + showTripPlanModal = false; + }); + + window.addEventListener('planTripTabClicked', () => { + closePane(); + }); + } }); @@ -158,6 +189,7 @@ on:routeSelected={handleRouteSelected} on:clearResults={clearPolylines} on:viewAllRoutes={handleShowAllRoutes} + on:tripPlanned={handleTripPlan} />
@@ -182,6 +214,17 @@ on:routeSelected={handleRouteSelectedFromModal} /> {/if} + + {#if showTripPlanModal} + + {/if}
diff --git a/src/routes/api/oba/google-geocode-location/+server.js b/src/routes/api/oba/google-geocode-location/+server.js new file mode 100644 index 0000000..27cc05c --- /dev/null +++ b/src/routes/api/oba/google-geocode-location/+server.js @@ -0,0 +1,31 @@ +import { googleGeocode } from '$lib/geocoder'; + +import { + PRIVATE_OBA_GEOCODER_API_KEY as geocoderApiKey, + PRIVATE_OBA_GEOCODER_PROVIDER as geocoderProvider +} from '$env/static/private'; + +async function locationSearch(query) { + if (geocoderProvider === 'google') { + return googleGeocode({ apiKey: geocoderApiKey, query }); + } else { + return []; + } +} + +export async function GET({ url }) { + const searchInput = url.searchParams.get('query')?.trim(); + + const locationResponse = await locationSearch(searchInput); + + return new Response( + JSON.stringify({ + location: locationResponse + }), + { + headers: { + 'Content-Type': 'application/json' + } + } + ); +} diff --git a/src/routes/api/oba/google-place-autocomplete/+server.js b/src/routes/api/oba/google-place-autocomplete/+server.js new file mode 100644 index 0000000..8400bea --- /dev/null +++ b/src/routes/api/oba/google-place-autocomplete/+server.js @@ -0,0 +1,30 @@ +import { googlePlacesAutocomplete } from '$lib/geocoder'; + +import { + PRIVATE_OBA_GEOCODER_API_KEY as geocoderApiKey, + PRIVATE_OBA_GEOCODER_PROVIDER as geocoderProvider +} from '$env/static/private'; + +async function autoCompletePlacesSearch(input) { + if (geocoderProvider === 'google') { + return googlePlacesAutocomplete({ apiKey: geocoderApiKey, input }); + } else { + return []; + } +} + +export async function GET({ url }) { + const searchInput = url.searchParams.get('query')?.trim(); + + const suggestions = await autoCompletePlacesSearch(searchInput); + return new Response( + JSON.stringify({ + suggestions + }), + { + headers: { + 'Content-Type': 'application/json' + } + } + ); +} diff --git a/src/routes/api/otp/plan/+server.js b/src/routes/api/otp/plan/+server.js index 1f76039..33386fe 100644 --- a/src/routes/api/otp/plan/+server.js +++ b/src/routes/api/otp/plan/+server.js @@ -1,31 +1,35 @@ import { error, json } from '@sveltejs/kit'; -export async function GET() { - try { - const response = await fetch( - 'https://otp.prod.sound.obaweb.org/otp/routers/default/plan?fromPlace=47.5423055%2C-122.38677&toPlace=47.639376%2C-122.128238', - { - headers: { - 'Accept': 'application/json' - } - } - ); +export async function GET({ url }) { + const fromPlace = url.searchParams.get('fromPlace'); + const toPlace = url.searchParams.get('toPlace'); - if (!response.ok) { - throw error(response.status, `OpenTripPlanner API returned status ${response.status}`); - } + if (!fromPlace || !toPlace) { + throw error(400, 'Missing required parameters: fromPlace and toPlace'); + } - const data = await response.json(); - return json(data); + try { + const response = await fetch( + `https://otp.prod.sound.obaweb.org/otp/routers/default/plan?fromPlace=${encodeURIComponent(fromPlace)}&toPlace=${encodeURIComponent(toPlace)}`, + { + headers: { + Accept: 'application/json' + } + } + ); - } catch (err) { - // If it's already a SvelteKit error, rethrow it - if (err.status) throw err; + if (!response.ok) { + throw error(response.status, `OpenTripPlanner API returned status ${response.status}`); + } - // Otherwise wrap it in a 500 error - throw error(500, { - message: 'Failed to fetch trip planning data', - error: err.message - }); - } -} \ No newline at end of file + const data = await response.json(); + return json(data); + } catch (err) { + if (err.status) throw err; + + throw error(500, { + message: 'Failed to fetch trip planning data', + error: err.message + }); + } +} diff --git a/src/stores/mapStore.js b/src/stores/mapStore.js new file mode 100644 index 0000000..b1f1a9e --- /dev/null +++ b/src/stores/mapStore.js @@ -0,0 +1,3 @@ +import { writable } from 'svelte/store'; + +export const isMapLoaded = writable(false);