• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

OneBusAway / wayfinder / 18223916783

03 Oct 2025 01:38PM UTC coverage: 73.928% (-0.8%) from 74.699%
18223916783

Pull #273

github

web-flow
Merge 4b63c554d into b0a407d37
Pull Request #273: Refactor/map-state-simplification

839 of 957 branches covered (87.67%)

Branch coverage included in aggregate %.

4 of 151 new or added lines in 9 files covered. (2.65%)

9 existing lines in 4 files now uncovered.

6681 of 9215 relevant lines covered (72.5%)

1.9 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/src/components/map/MapView.svelte
1
<script>
×
2
        import { browser } from '$app/environment';
3
        import { onMount, onDestroy } from 'svelte';
4
        import {
5
                PUBLIC_OBA_REGION_CENTER_LAT as initialLat,
6
                PUBLIC_OBA_REGION_CENTER_LNG as initialLng
7
        } from '$env/static/public';
8

9
        import { debounce } from '$lib/utils';
10
        import LocationButton from '$lib/LocationButton/LocationButton.svelte';
11
        import RouteMap from './RouteMap.svelte';
12

13
        import { isMapLoaded } from '$src/stores/mapStore';
14
        import { userLocation } from '$src/stores/userLocationStore';
15
        /**
16
         * @typedef {Object} Props
17
         * @property {any} [selectedTrip]
18
         * @property {any} [selectedRoute]
19
         * @property {boolean} [showRoute]
20
         * @property {boolean} [showRouteMap]
21
         * @property {any} [mapProvider]
22
         * @property {any} [stop] - Currently selected stop to preserve visual context
23
         */
24

25
        /** @type {Props} */
26
        let {
27
                handleStopMarkerSelect,
28
                selectedTrip = null,
×
29
                selectedRoute = null,
×
NEW
30
                isRouteSelected = false,
×
31
                showRouteMap = false,
×
NEW
32
                mapProvider = null,
×
NEW
33
                stop = null
×
34
        } = $props();
35

36
        let isTripPlanModeActive = $state(false);
×
37
        let mapInstance = $state(null);
×
38
        let mapElement = $state();
×
39
        let allStops = $state([]);
×
40
        // O(1) lookup for existing stops
41
        let allStopsMap = new Map();
×
42
        let stopsCache = new Map();
×
43

NEW
44
        const Modes = {
×
NEW
45
                NORMAL: 'normal',
×
NEW
46
                TRIP_PLAN: 'tripPlan',
×
NEW
47
                ROUTE: 'route'
×
48
        };
49

NEW
50
        let mapMode = $state(Modes.NORMAL);
×
NEW
51
        let modeChangeTimeout = null;
×
52

NEW
53
        $effect(() => {
×
NEW
54
                let newMode;
×
NEW
55
                if (isTripPlanModeActive) {
×
NEW
56
                        newMode = Modes.TRIP_PLAN;
×
NEW
57
                } else if (selectedRoute || isRouteSelected || showRouteMap || selectedTrip) {
×
NEW
58
                        newMode = Modes.ROUTE;
×
59
                } else {
NEW
60
                        newMode = Modes.NORMAL;
×
61
                }
NEW
62
                if (modeChangeTimeout) {
×
NEW
63
                        clearTimeout(modeChangeTimeout);
×
64
                }
NEW
65
                if (mapMode === Modes.ROUTE && newMode === Modes.NORMAL) {
×
NEW
66
                        modeChangeTimeout = setTimeout(() => {
×
NEW
67
                                mapMode = newMode;
×
NEW
68
                        }, 100);
×
NEW
69
                } else if (mapMode !== newMode) {
×
NEW
70
                        mapMode = newMode;
×
71
                }
72
        });
73

NEW
74
        $effect(() => {
×
NEW
75
                if (!mapInstance) return;
×
NEW
76
                if (mapMode === Modes.NORMAL) {
×
NEW
77
                        batchAddMarkers(allStops);
×
78
                } else {
NEW
79
                        clearAllMarkers();
×
80
                }
81
        });
82

83
        function cacheKey(zoomLevel, boundingBox) {
×
84
                const multiplier = 100; // 2 decimal places
×
85
                const north = Math.round(boundingBox.north * multiplier);
×
86
                const south = Math.round(boundingBox.south * multiplier);
×
87
                const east = Math.round(boundingBox.east * multiplier);
×
88
                const west = Math.round(boundingBox.west * multiplier);
×
89

90
                return `${north}_${south}_${east}_${west}_${zoomLevel}`;
×
91
        }
92

93
        function getBoundingBox() {
×
94
                if (!mapProvider) {
×
95
                        throw new Error('Map provider is not initialized');
×
96
                }
97
                return mapProvider.getBoundingBox();
×
98
        }
99

100
        async function loadStopsForLocation(lat, lng, zoomLevel, firstCall = false) {
×
101
                if (firstCall) {
×
102
                        const response = await fetch(`/api/oba/stops-for-location?lat=${lat}&lng=${lng}&radius=2500`);
×
103
                        if (!response.ok) {
×
104
                                throw new Error('Failed to fetch locations');
×
105
                        }
106
                        return await response.json();
×
107
                }
108

109
                const boundingBox = getBoundingBox();
×
110
                const key = cacheKey(zoomLevel, boundingBox);
×
111

112
                if (stopsCache.has(key)) {
×
113
                        console.debug('Stop cache hit: ', key);
×
114
                        return stopsCache.get(key);
×
115
                } else {
116
                        console.debug('Stop cache miss: ', key);
×
117
                }
118

119
                const response = await fetch(
×
120
                        `/api/oba/stops-for-location?lat=${lat}&lng=${lng}&latSpan=${boundingBox.north - boundingBox.south}&lngSpan=${boundingBox.east - boundingBox.west}&radius=1500`
×
121
                );
122

123
                if (!response.ok) {
×
124
                        throw new Error('Failed to fetch locations');
×
125
                }
126

127
                const stopsForLocation = await response.json();
×
128
                stopsCache.set(key, stopsForLocation);
×
129

130
                return stopsForLocation;
×
131
        }
132

133
        async function initMap() {
×
134
                try {
135
                        await mapProvider.initMap(mapElement, {
×
136
                                lat: Number(initialLat),
×
137
                                lng: Number(initialLng)
×
138
                        });
139

140
                        mapInstance = mapProvider;
×
141

142
                        await loadStopsAndAddMarkers(initialLat, initialLng, true);
×
143

144
                        const debouncedLoadMarkers = debounce(async () => {
×
NEW
145
                                if (mapMode !== Modes.NORMAL) {
×
146
                                        return;
147
                                }
148

NEW
149
                                const center = mapInstance.getCenter();
×
NEW
150
                                const zoomLevel = mapInstance.map.getZoom();
×
151
                                await loadStopsAndAddMarkers(center.lat, center.lng, false, zoomLevel);
×
152
                        }, 300);
×
153

154
                        mapProvider.eventListeners(mapInstance, debouncedLoadMarkers);
×
155

156
                        if (browser) {
×
157
                                window.addEventListener('themeChange', handleThemeChange);
×
158
                        }
159
                } catch (error) {
×
160
                        console.error('Error initializing map:', error);
×
161
                }
162
        }
163

164
        async function loadStopsAndAddMarkers(lat, lng, firstCall = false, zoomLevel = 15) {
×
165
                const stopsData = await loadStopsForLocation(lat, lng, zoomLevel, firstCall);
×
166
                const newStops = stopsData.data.list;
×
167
                const routeReference = stopsData.data.references.routes || [];
×
168

169
                const routeLookup = new Map(routeReference.map((route) => [route.id, route]));
×
170

171
                // merge the stops routeIds with the route data and deduplicate efficiently
172
                newStops.forEach((stop) => {
×
173
                        if (!allStopsMap.has(stop.id)) {
×
174
                                stop.routes =
×
175
                                        stop.routeIds?.map((routeId) => routeLookup.get(routeId)).filter(Boolean) || [];
×
176
                                allStopsMap.set(stop.id, stop);
×
177
                        }
178
                });
179

180
                allStops = Array.from(allStopsMap.values());
×
181
        }
182

183
        function clearAllMarkers() {
×
184
                if (mapInstance && mapInstance.clearAllStopMarkers) {
×
185
                        mapInstance.clearAllStopMarkers();
×
186
                }
187
        }
188

189
        function updateMarkers() {
×
190
                if (!selectedRoute && !isTripPlanModeActive) {
×
191
                        batchAddMarkers(allStops);
×
192
                }
193
        }
194

195
        // Batch operation to add multiple markers efficiently
196
        function batchAddMarkers(stops) {
×
197
                const stopsToAdd = stops.filter((s) => !mapInstance.hasMarker(s.id));
×
198

NEW
199
                if (stopsToAdd.length === 0) {
×
200
                        return;
201
                }
202

203
                // Group DOM operations to minimize reflows/repaints
204
                requestAnimationFrame(() => {
×
205
                        stopsToAdd.forEach((s) => addMarker(s));
×
206
                });
207
        }
208

209
        function addMarker(s) {
×
210
                if (!mapInstance) {
×
211
                        console.error('Map not initialized yet');
×
212
                        return;
213
                }
214

UNCOV
215
                if (mapInstance.hasMarker(s.id)) {
×
216
                        return;
217
                }
218

219
                // Check if this marker should be highlighted (if it's the currently selected stop)
NEW
220
                const shouldHighlight = stop && s.id === stop.id;
×
221

222
                const markerObj = mapInstance.addMarker({
×
223
                        position: { lat: s.lat, lng: s.lon },
×
224
                        stop: s,
×
NEW
225
                        isHighlighted: shouldHighlight,
×
226
                        onClick: () => {
×
227
                                handleStopMarkerSelect(s);
×
228
                        }
229
                });
230

231
                return markerObj;
×
232
        }
233

234
        function handleThemeChange(event) {
×
235
                const { darkMode } = event.detail;
×
236
                mapInstance.setTheme(darkMode ? 'dark' : 'light');
×
237
        }
238

239
        function handleLocationObtained(latitude, longitude) {
×
240
                mapInstance.setCenter({ lat: latitude, lng: longitude });
×
241
                mapInstance.addUserLocationMarker({ lat: latitude, lng: longitude });
×
242
                userLocation.set({ lat: latitude, lng: longitude });
×
243
        }
244

245
        // Store event handlers for proper cleanup
246
        let planTripHandler, tabSwitchHandler;
×
247

248
        onMount(async () => {
×
249
                await initMap();
×
250
                isMapLoaded.set(true);
×
251
                if (browser) {
×
252
                        const darkMode = document.documentElement.classList.contains('dark');
×
253

254
                        // Store handlers for cleanup
255
                        planTripHandler = () => {
×
256
                                isTripPlanModeActive = true;
×
257
                        };
258
                        tabSwitchHandler = () => {
×
259
                                isTripPlanModeActive = false;
×
260
                        };
261

262
                        window.addEventListener('planTripTabClicked', planTripHandler);
×
263
                        window.addEventListener('tabSwitched', tabSwitchHandler);
×
264

265
                        const event = new CustomEvent('themeChange', { detail: { darkMode } });
×
266
                        window.dispatchEvent(event);
×
267
                }
268
        });
269

270
        onDestroy(() => {
×
271
                if (browser) {
×
272
                        window.removeEventListener('themeChange', handleThemeChange);
×
273

274
                        if (planTripHandler) window.removeEventListener('planTripTabClicked', planTripHandler);
×
275
                        if (tabSwitchHandler) window.removeEventListener('tabSwitched', tabSwitchHandler);
×
276
                }
277

NEW
278
                if (modeChangeTimeout) {
×
NEW
279
                        clearTimeout(modeChangeTimeout);
×
280
                }
281

UNCOV
282
                clearAllMarkers();
×
283

284
                allStopsMap.clear();
×
285
                stopsCache.clear();
×
286
        });
287
</script>
288

289
<div class="map-container">
290
        <div id="map" bind:this={mapElement}></div>
×
291

292
        {#if selectedTrip && showRouteMap}
×
NEW
293
                <RouteMap mapProvider={mapInstance} tripId={selectedTrip.tripId} currentSelectedStop={stop} />
×
294
        {/if}
295
</div>
296

297
<div class="controls">
298
        <LocationButton {handleLocationObtained} />
299
</div>
300

301
<style>
302
        .map-container {
303
                position: relative;
304
                height: 100%;
305
                width: 100%;
306
                z-index: 1;
307
        }
308
        #map {
309
                height: 100%;
310
                width: 100%;
311
        }
312
</style>
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc