diff --git a/.env.example b/.env.example index b6e2a1a..f2bb6c1 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,20 @@ PRIVATE_OBA_API_KEY="test" + PRIVATE_OBA_GEOCODER_API_KEY="" PRIVATE_OBA_GEOCODER_PROVIDER="google" + PRIVATE_OBACO_API_BASE_URL=https://onebusaway.co/api/v1/regions/:REGION_ID PRIVATE_OBACO_SHOW_TEST_ALERTS=false + PUBLIC_NAV_BAR_LINKS={"Home": "/","About": "/about","Contact": "/contact","Fares & Tolls": "/fares-and-tolls"} PUBLIC_OBA_GOOGLE_MAPS_API_KEY="" PUBLIC_OBA_LOGO_URL="https://onebusaway.org/wp-content/uploads/oba_logo-1.png" PUBLIC_OBA_MAP_PROVIDER="osm" + PUBLIC_OBA_REGION_CENTER_LAT=47.60728155903877 PUBLIC_OBA_REGION_CENTER_LNG=-122.3339240843084 PUBLIC_OBA_REGION_NAME="Puget Sound" + PUBLIC_OBA_SERVER_URL="https://api.pugetsound.onebusaway.org/" + +PUBLIC_OTP_SERVER_URL="" diff --git a/README.md b/README.md index d4f8635..a9543ff 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,13 @@ 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. + ## Building To create a production version of your app: diff --git a/src/components/map/MapView.svelte b/src/components/map/MapView.svelte index e0910ee..b330414 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 stop = null; export let mapProvider = null; + let isTripPlanMoodActive = false; + let selectedStopID = null; let mapInstance = null; let mapElement; @@ -169,6 +172,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) { if (!mapInstance) { console.error('Map not initialized yet'); @@ -221,8 +235,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 a37d00d..5917950 100644 --- a/src/components/search/SearchPane.svelte +++ b/src/components/search/SearchPane.svelte @@ -8,10 +8,15 @@ import { t } from 'svelte-i18n'; 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; @@ -19,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; @@ -54,7 +52,6 @@ for (const polylineData of polylinesData) { const shape = polylineData.points; let polyline; - polyline = mapProvider.createPolyline(shape); polylines.push(polyline); } @@ -62,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 }); } @@ -100,70 +95,96 @@ 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); }); }); -
-
- - - {#if query} -

- {$t('search.results_for')} "{query}". - -

- {/if} - -
- {#if location} - handleLocationClick(location)} - title={location.formatted_address} - icon={faMapPin} - subtitle={location.types.join(', ')} - /> +
+ + + + + {#if query} +

+ {$t('search.results_for')} "{query}". + +

{/if} - {#if routes?.length > 0} - {#each routes as route} +
+ {#if location} handleRouteClick(route)} - icon={prioritizedRouteTypeForDisplay(route.type)} - title={`${$t('route')} ${route.nullSafeShortName || route.id}`} - subtitle={route.description} + on:click={() => handleLocationClick(location)} + title={location.formatted_address} + icon={faMapPin} + subtitle={location.types.join(', ')} /> - {/each} - {/if} - - {#if stops?.length > 0} - {#each stops as stop} - handleStopClick(stop)} - icon={faSignsPost} - title={stop.name} - subtitle={`${compassDirection(stop.direction)}; Code: ${stop.code}`} - /> - {/each} - {/if} -
- -
- - - {$t('search.for_a_list_of_available_routes')} -
-
+ {/if} + + {#if routes?.length > 0} + {#each routes as route} + handleRouteClick(route)} + icon={prioritizedRouteTypeForDisplay(route.type)} + title={`${$t('route')} ${route.nullSafeShortName || route.id}`} + subtitle={route.description} + /> + {/each} + {/if} + + {#if stops?.length > 0} + {#each stops as stop} + handleStopClick(stop)} + icon={faSignsPost} + title={stop.name} + subtitle={`${compassDirection(stop.direction)}; Code: ${stop.code}`} + /> + {/each} + {/if} +
+ +
+ + + {$t('search.for_a_list_of_available_routes')} +
+ + + {#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 1781a3a..9ab944d 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 f05b964..91747d5 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 329d666..8ad76f7 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; @@ -15,6 +17,7 @@ let selectedRoute = null; let showRouteMap = false; let showAllRoutesModal = false; + let showTripPlanModal = false; let showRouteModal; let mapProvider = null; let currentIntervalId = null; @@ -23,6 +26,11 @@ let polylines = []; let stops = []; + let tripItineraries = []; + let loadingItineraries = false; + let fromMarker = null; + let toMarker = null; + $: { if (showRouteModal && showAllRoutesModal) { showAllRoutesModal = false; @@ -75,6 +83,7 @@ showAllRoutesModal = false; mapProvider.unHighlightMarker(currentHighlightedStopId); currentHighlightedStopId = null; + showTripPlanModal = false; } function tripSelected(event) { @@ -131,8 +140,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(); + }); + } }); @@ -151,6 +182,7 @@ on:routeSelected={handleRouteSelected} on:clearResults={clearPolylines} on:viewAllRoutes={handleShowAllRoutes} + on:tripPlanned={handleTripPlan} />
@@ -173,6 +205,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 new file mode 100644 index 0000000..33386fe --- /dev/null +++ b/src/routes/api/otp/plan/+server.js @@ -0,0 +1,35 @@ +import { error, json } from '@sveltejs/kit'; + +export async function GET({ url }) { + const fromPlace = url.searchParams.get('fromPlace'); + const toPlace = url.searchParams.get('toPlace'); + + if (!fromPlace || !toPlace) { + throw error(400, 'Missing required parameters: fromPlace and toPlace'); + } + + 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' + } + } + ); + + if (!response.ok) { + throw error(response.status, `OpenTripPlanner API returned status ${response.status}`); + } + + 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);