Skip to content

Commit

Permalink
Merge pull request #108 from OneBusAway/feature/trip-planner
Browse files Browse the repository at this point in the history
DRAFT: Trip Planner
  • Loading branch information
aaronbrethorst authored Nov 13, 2024
2 parents 1231c37 + fbfa079 commit 3333129
Show file tree
Hide file tree
Showing 20 changed files with 965 additions and 71 deletions.
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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=""
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 21 additions & 0 deletions src/components/map/MapView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -20,6 +21,8 @@
export let stop = null;
export let mapProvider = null;
let isTripPlanMoodActive = false;
let selectedStopID = null;
let mapInstance = null;
let mapElement;
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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);
}
Expand Down
153 changes: 87 additions & 66 deletions src/components/search/SearchPane.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,44 +8,42 @@
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;
let location = null;
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;
Expand All @@ -54,18 +52,15 @@
for (const polylineData of polylinesData) {
const shape = polylineData.points;
let polyline;
polyline = mapProvider.createPolyline(shape);
polylines.push(polyline);
}
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 });
}
Expand Down Expand Up @@ -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);
});
});
</script>

<div class={`modal-pane flex justify-between md:w-96 ${cssClasses}`}>
<div class="flex w-full flex-col gap-y-2 py-4">
<SearchField value={query} on:searchResults={handleSearchResults} />

{#if query}
<p class="text-sm text-gray-700 dark:text-gray-400">
{$t('search.results_for')} "{query}".
<button type="button" on:click={clearResults} class="text-blue-600 hover:underline">
{$t('search.clear_results')}
</button>
</p>
{/if}

<div class="max-h-96 overflow-y-auto">
{#if location}
<SearchResultItem
on:click={() => handleLocationClick(location)}
title={location.formatted_address}
icon={faMapPin}
subtitle={location.types.join(', ')}
/>
<div class={`modal-pane flex flex-col justify-between md:w-96 ${cssClasses}`}>
<Tabs tabStyle="underline" contentClass="pt-2 pb-4 bg-gray-50 rounded-lg dark:bg-black">
<TabItem open title="Stops and Stations" on:click={handleTabSwitch}>
<SearchField value={query} on:searchResults={handleSearchResults} />

{#if query}
<p class="text-sm text-gray-700 dark:text-gray-400">
{$t('search.results_for')} "{query}".
<button type="button" on:click={clearResults} class="text-blue-600 hover:underline">
{$t('search.clear_results')}
</button>
</p>
{/if}

{#if routes?.length > 0}
{#each routes as route}
<div class="max-h-96 overflow-y-auto">
{#if location}
<SearchResultItem
on:click={() => 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}
<SearchResultItem
on:click={() => handleStopClick(stop)}
icon={faSignsPost}
title={stop.name}
subtitle={`${compassDirection(stop.direction)}; Code: ${stop.code}`}
/>
{/each}
{/if}
</div>

<div class="mt-0 sm:mt-0">
<button
type="button"
class="text-sm font-medium text-green-600 underline hover:text-green-400 focus:outline-none"
on:click={handleViewAllRoutes}
>
{$t('search.click_here')}
</button>
<span class="text-sm font-medium text-black dark:text-white">
{$t('search.for_a_list_of_available_routes')}</span
>
</div>
</div>
{/if}

{#if routes?.length > 0}
{#each routes as route}
<SearchResultItem
on:click={() => 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}
<SearchResultItem
on:click={() => handleStopClick(stop)}
icon={faSignsPost}
title={stop.name}
subtitle={`${compassDirection(stop.direction)}; Code: ${stop.code}`}
/>
{/each}
{/if}
</div>

<div class="mt-0 sm:mt-0">
<button
type="button"
class="mt-3 text-sm font-medium text-green-600 underline hover:text-green-400 focus:outline-none"
on:click={handleViewAllRoutes}
>
{$t('search.click_here')}
</button>
<span class="text-sm font-medium text-black dark:text-white">
{$t('search.for_a_list_of_available_routes')}</span
>
</div>
</TabItem>

{#if PUBLIC_OTP_SERVER_URL}
<TabItem title="Plan a Trip" on:click={handlePlanTripTabClick} disabled={!mapLoaded}>
<TripPlan {mapProvider} on:tripPlanned={handleTripPlan} />
</TabItem>
{/if}
</Tabs>
</div>
27 changes: 27 additions & 0 deletions src/components/trip-planner/ItineraryDetails.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<script>
import LegDetails from './LegDetails.svelte';
import { formatTime } from '$lib/formatters';
export let itinerary;
export let expandedSteps;
export let toggleSteps;
</script>

<div class="mb-4 flex items-center justify-between rounded-lg bg-gray-100 p-4 shadow-md">
<div class="text-center">
<p class="text-sm font-semibold text-gray-500">Duration</p>
<p class="text-lg font-bold text-gray-700">{Math.round(itinerary.duration / 60)} min</p>
</div>
<div class="text-center">
<p class="text-sm font-semibold text-gray-500">Start Time</p>
<p class="text-lg font-bold text-gray-700">{formatTime(itinerary.startTime)}</p>
</div>
<div class="text-center">
<p class="text-sm font-semibold text-gray-500">End Time</p>
<p class="text-lg font-bold text-gray-700">{formatTime(itinerary.endTime)}</p>
</div>
</div>
<div class="space-y-4">
{#each itinerary.legs as leg, index}
<LegDetails {leg} {index} {expandedSteps} {toggleSteps} />
{/each}
</div>
14 changes: 14 additions & 0 deletions src/components/trip-planner/ItineraryTab.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<script>
export let index;
export let activeTab;
export let setActiveTab;
</script>

<button
class={`-mb-px px-4 py-2 font-semibold ${
activeTab === index ? 'border-b-2 border-green-500 text-green-500' : 'text-gray-500'
}`}
on:click={() => setActiveTab(index)}
>
Itinerary {index + 1}
</button>
Loading

0 comments on commit 3333129

Please sign in to comment.