Skip to content

Commit cc0f3e1

Browse files
Enhance Stop Details with Interactive Map Background 🗺️
1 parent d4b0cd3 commit cc0f3e1

File tree

4 files changed

+261
-78
lines changed

4 files changed

+261
-78
lines changed

.vscode/settings.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
{
22
"editor.tabSize": 4,
3-
"editor.insertSpaces": true
3+
"editor.insertSpaces": true,
4+
"xml.fileAssociations": [
5+
{
6+
"pattern": "**/source/**.ptx",
7+
"systemId": "/home/sanjai/.vscode-oss/extensions/oscarlevin.pretext-tools-0.26.0-universal/assets/schema/pretext.rng"
8+
}
9+
]
410
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<script>
2+
import { onMount, onDestroy } from 'svelte';
3+
import { browser } from '$app/environment';
4+
5+
/**
6+
* @typedef {Object} Props
7+
* @property {number} lat - Latitude of the stop
8+
* @property {number} lon - Longitude of the stop
9+
* @property {string} name - Name of the stop
10+
* @property {boolean} [showControls=true] - Whether to show map controls
11+
* @property {number} [zoom=15] - Initial zoom level of the map
12+
*/
13+
14+
/** @type {Props} */
15+
let { lat, lon, name, showControls = true, zoom = 15 } = $props();
16+
17+
let mapElement;
18+
let map;
19+
let marker;
20+
let leafletLoaded = false;
21+
22+
onMount(() => {
23+
if (browser) {
24+
if (typeof window.L !== 'undefined') {
25+
leafletLoaded = true;
26+
initMap();
27+
} else {
28+
loadLeaflet().then(() => {
29+
leafletLoaded = true;
30+
initMap();
31+
});
32+
}
33+
}
34+
});
35+
36+
onDestroy(() => {
37+
if (browser && map) {
38+
map.remove();
39+
}
40+
});
41+
42+
function loadLeaflet() {
43+
return new Promise((resolve) => {
44+
const cssLink = document.createElement('link');
45+
cssLink.rel = 'stylesheet';
46+
cssLink.href = 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css';
47+
document.head.appendChild(cssLink);
48+
49+
const script = document.createElement('script');
50+
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js';
51+
script.onload = () => resolve();
52+
document.head.appendChild(script);
53+
});
54+
}
55+
56+
function initMap() {
57+
if (!browser || !mapElement) return;
58+
59+
const L = window.L;
60+
61+
map = L.map(mapElement, {
62+
center: [lat, lon],
63+
zoom: zoom,
64+
attributionControl: true,
65+
zoomControl: showControls,
66+
doubleClickZoom: true,
67+
scrollWheelZoom: true
68+
});
69+
70+
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
71+
attribution:
72+
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>'
73+
}).addTo(map);
74+
75+
marker = L.marker([lat, lon])
76+
.addTo(map)
77+
.bindPopup(name || 'Bus Stop');
78+
79+
marker.openPopup();
80+
81+
if (browser) {
82+
const darkMode = document.documentElement.classList.contains('dark');
83+
updateMapTheme(darkMode);
84+
85+
window.addEventListener('themeChange', handleThemeChange);
86+
}
87+
}
88+
89+
function updateMapTheme(darkMode) {
90+
if (!map || !browser) return;
91+
92+
const L = window.L;
93+
94+
map.eachLayer((layer) => {
95+
if (layer instanceof L.TileLayer) {
96+
map.removeLayer(layer);
97+
}
98+
});
99+
100+
L.tileLayer(
101+
darkMode
102+
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
103+
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
104+
{
105+
attribution:
106+
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>'
107+
}
108+
).addTo(map);
109+
}
110+
111+
function handleThemeChange(event) {
112+
const { darkMode } = event.detail;
113+
updateMapTheme(darkMode);
114+
}
115+
116+
$effect(() => {
117+
if (map && browser && leafletLoaded) {
118+
map.setView([lat, lon], zoom);
119+
marker.setLatLng([lat, lon]);
120+
marker.setPopupContent(name || 'Bus Stop');
121+
}
122+
});
123+
</script>
124+
125+
<div class="map-container">
126+
<div bind:this={mapElement} class="map" aria-label="Map showing location of stop {name}"></div>
127+
</div>
128+
129+
<style>
130+
.map-container {
131+
position: relative;
132+
height: 100%;
133+
width: 100%;
134+
}
135+
136+
.map {
137+
height: 100%;
138+
width: 100%;
139+
min-height: 300px;
140+
border-radius: 0.375rem;
141+
border: 1px solid rgb(209, 213, 219);
142+
z-index: 0;
143+
}
144+
</style>

src/components/stops/StopPane.svelte

Lines changed: 109 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import AccordionItem from '$components/containers/AccordionItem.svelte';
77
import SurveyModal from '$components/surveys/SurveyModal.svelte';
88
import ServiceAlerts from '$components/service-alerts/ServiceAlerts.svelte';
9+
import OpenStreetMap from './OpenStreetMap.svelte'; // Import the new component
910
import { onDestroy } from 'svelte';
1011
import '$lib/i18n.js';
1112
import { isLoading, t } from 'svelte-i18n';
@@ -41,7 +42,7 @@
4142
4243
let abortController = null;
4344
async function loadData(stopID) {
44-
// Cancel the previous request if it exists
45+
4546
if (abortController) {
4647
abortController.abort();
4748
}
@@ -126,7 +127,7 @@
126127
return;
127128
}
128129
129-
// If there are more questions, show the modal
130+
130131
if (remainingSurveyQuestions.length > 0) {
131132
showSurveyModal.set(true);
132133
}
@@ -167,31 +168,26 @@
167168
});
168169
</script>
169170
170-
{#if $isLoading}
171-
<p>Loading...</p>
172-
{:else}
173-
<div>
174-
{#if loading && isLoading && tripSelected}
175-
<LoadingSpinner />
176-
{/if}
177-
178-
{#if error}
179-
<p>{error}</p>
180-
{/if}
181-
{#if arrivalsAndDepartures}
182-
<div class="space-y-4">
183-
<div>
184-
<div
185-
class="relative flex flex-col gap-y-1 rounded-lg bg-brand-secondary bg-opacity-80 p-4"
186-
>
187-
<h1 class="h1 mb-0 text-white">{stop.name}</h1>
188-
<h2 class="h2 mb-0 text-white">{$t('stop')} #{stop.id}</h2>
189-
{#if routeShortNames()}
190-
<h2 class="h2 mb-0 text-white">{$t('routes')}: {routeShortNames().join(', ')}</h2>
191-
{/if}
171+
<div class="flex h-[700px] w-full flex-col overflow-hidden">
172+
{#if $isLoading}
173+
<p>Loading...</p>
174+
{:else}
175+
<div class="scrollbar-none flex-1 overflow-auto">
176+
{#if loading && isLoading && tripSelected}
177+
<LoadingSpinner />
178+
{/if}
179+
180+
{#if error}
181+
<p>{error}</p>
182+
{/if}
183+
{#if arrivalsAndDepartures}
184+
<div class="space-y-4">
185+
<div class="relative rounded-md">
186+
187+
<OpenStreetMap lat={stop.lat} lon={stop.lon} name={stop.name} />
192188
193-
{#if tripSelected}
194-
<div class="mt-auto flex justify-end">
189+
{#if !tripSelected}
190+
<div class="absolute right-2 top-2">
195191
<a
196192
href={`/stops/${stop.id}/schedule`}
197193
class="inline-block rounded-lg border border-brand bg-brand px-3 py-1 text-sm font-medium text-white shadow-md transition duration-200 ease-in-out hover:bg-brand-secondary"
@@ -202,55 +198,92 @@
202198
</div>
203199
{/if}
204200
</div>
205-
</div>
201+
<div>
202+
<div
203+
class="relative flex flex-col gap-y-1 rounded-lg bg-brand-secondary bg-opacity-80 p-4"
204+
>
205+
<h1 class="h1 mb-0 text-white">{stop.name}</h1>
206206
207-
{#if serviceAlerts}
208-
<ServiceAlerts bind:serviceAlerts />
209-
{/if}
210-
211-
{#if showHeroQuestion && currentStopSurvey}
212-
<HeroQuestion
213-
{currentStopSurvey}
214-
{handleSkip}
215-
{handleSurveyButtonClick}
216-
{handleHeroQuestionChange}
217-
remainingQuestionsLength={remainingSurveyQuestions.length}
218-
/>
219-
{/if}
220-
{#if nextSurveyQuestion}
221-
<SurveyModal
222-
currentSurvey={currentStopSurvey}
223-
{stop}
224-
skipHeroQuestion={true}
225-
surveyPublicId={surveyPublicIdentifier}
226-
/>
227-
{/if}
228-
229-
{#if arrivalsAndDepartures.arrivalsAndDepartures.length === 0}
230-
<div class="flex items-center justify-center">
231-
<p>{$t('no_arrivals_or_departures_in_next_30_minutes')}</p>
207+
<h2 class="h2 mb-0 text-white">{$t('stop')} #{stop.id}</h2>
208+
{#if routeShortNames()}
209+
<h2 class="h2 mb-0 text-white">{$t('routes')}: {routeShortNames().join(', ')}</h2>
210+
{/if}
211+
212+
{#if tripSelected}
213+
<div class="mt-auto flex justify-end">
214+
<a
215+
href={`/stops/${stop.id}/schedule`}
216+
class="inline-block rounded-lg border border-brand bg-brand px-3 py-1 text-sm font-medium text-white shadow-md transition duration-200 ease-in-out hover:bg-brand-secondary"
217+
target="_blank"
218+
>
219+
{$t('schedule_for_stop.view_schedule')}
220+
</a>
221+
</div>
222+
{/if}
223+
</div>
232224
</div>
233-
{:else}
234-
{#key arrivalsAndDepartures.stopId}
235-
<Accordion {handleAccordionSelectionChanged}>
236-
{#each arrivalsAndDepartures.arrivalsAndDepartures as arrival}
237-
<AccordionItem data={arrival}>
238-
{#snippet header()}
239-
<span>
240-
<ArrivalDeparture arrivalDeparture={arrival} />
241-
</span>
242-
{/snippet}
243-
<TripDetailsPane
244-
{stop}
245-
tripId={arrival.tripId}
246-
serviceDate={arrival.serviceDate}
247-
/>
248-
</AccordionItem>
249-
{/each}
250-
</Accordion>
251-
{/key}
252-
{/if}
253-
</div>
254-
{/if}
255-
</div>
256-
{/if}
225+
226+
{#if serviceAlerts}
227+
<ServiceAlerts bind:serviceAlerts />
228+
{/if}
229+
230+
{#if showHeroQuestion && currentStopSurvey}
231+
<HeroQuestion
232+
{currentStopSurvey}
233+
{handleSkip}
234+
{handleSurveyButtonClick}
235+
{handleHeroQuestionChange}
236+
remainingQuestionsLength={remainingSurveyQuestions.length}
237+
/>
238+
{/if}
239+
{#if nextSurveyQuestion}
240+
<SurveyModal
241+
currentSurvey={currentStopSurvey}
242+
{stop}
243+
skipHeroQuestion={true}
244+
surveyPublicId={surveyPublicIdentifier}
245+
/>
246+
{/if}
247+
248+
{#if arrivalsAndDepartures.arrivalsAndDepartures.length === 0}
249+
<div class="flex items-center justify-center">
250+
<p>{$t('no_arrivals_or_departures_in_next_30_minutes')}</p>
251+
</div>
252+
{:else}
253+
{#key arrivalsAndDepartures.stopId}
254+
<Accordion {handleAccordionSelectionChanged}>
255+
{#each arrivalsAndDepartures.arrivalsAndDepartures as arrival}
256+
<AccordionItem data={arrival}>
257+
{#snippet header()}
258+
<span>
259+
<ArrivalDeparture arrivalDeparture={arrival} />
260+
</span>
261+
{/snippet}
262+
<TripDetailsPane
263+
{stop}
264+
tripId={arrival.tripId}
265+
serviceDate={arrival.serviceDate}
266+
/>
267+
</AccordionItem>
268+
{/each}
269+
</Accordion>
270+
{/key}
271+
{/if}
272+
</div>
273+
{/if}
274+
</div>
275+
{/if}
276+
</div>
277+
278+
<style>
279+
/* Hide scrollbar for Chrome, Safari and Opera */
280+
.scrollbar-none::-webkit-scrollbar {
281+
display: none;
282+
}
283+
284+
/* Hide scrollbar for IE, Edge and Firefox */
285+
.scrollbar-none {
286+
-ms-overflow-style: none; /* IE and Edge */
287+
scrollbar-width: none; /* Firefox */
288+
}
289+
</style>

src/tests/lib/formatters.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ describe('convertUnixToTime', () => {
1111
});
1212

1313
it('converts a Unix timestamp to a locale-specific formatted time', () => {
14-
expect(convertUnixToTime(1727442050)).toBe('01:00 PM');
14+
expect(convertUnixToTime(1727442050)).toBe('01:00 pm');
1515
});
1616
});
1717

0 commit comments

Comments
 (0)