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}
+
+
+
+
+
+
+
+
+
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}
+
+ {#each results as result}
+
+ {/each}
+
+ {/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 @@
+
+
+
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);