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

OneBusAway / wayfinder / 18250393962

04 Oct 2025 10:31PM UTC coverage: 73.966% (-0.7%) from 74.699%
18250393962

push

github

web-flow
Merge pull request #273 from OneBusAway/refactor/map-state-simplification

Refactor/map-state-simplification

839 of 957 branches covered (87.67%)

Branch coverage included in aggregate %.

10 of 159 new or added lines in 9 files covered. (6.29%)

9 existing lines in 4 files now uncovered.

6687 of 9218 relevant lines covered (72.54%)

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
        // Batch operation to add multiple markers efficiently
190
        function batchAddMarkers(stops) {
×
191
                const stopsToAdd = stops.filter((s) => !mapInstance.hasMarker(s.id));
×
192

NEW
193
                if (stopsToAdd.length === 0) {
×
194
                        return;
195
                }
196

197
                // Group DOM operations to minimize reflows/repaints
198
                requestAnimationFrame(() => {
×
199
                        stopsToAdd.forEach((s) => addMarker(s));
×
200
                });
201
        }
202

203
        function addMarker(s) {
×
204
                if (!mapInstance) {
×
205
                        console.error('Map not initialized yet');
×
206
                        return;
207
                }
208

UNCOV
209
                if (mapInstance.hasMarker(s.id)) {
×
210
                        return;
211
                }
212

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

216
                const markerObj = mapInstance.addMarker({
×
217
                        position: { lat: s.lat, lng: s.lon },
×
218
                        stop: s,
×
NEW
219
                        isHighlighted: shouldHighlight,
×
220
                        onClick: () => {
×
221
                                handleStopMarkerSelect(s);
×
222
                        }
223
                });
224

225
                return markerObj;
×
226
        }
227

228
        function handleThemeChange(event) {
×
229
                const { darkMode } = event.detail;
×
230
                mapInstance.setTheme(darkMode ? 'dark' : 'light');
×
231
        }
232

233
        function handleLocationObtained(latitude, longitude) {
×
234
                mapInstance.setCenter({ lat: latitude, lng: longitude });
×
235
                mapInstance.addUserLocationMarker({ lat: latitude, lng: longitude });
×
236
                userLocation.set({ lat: latitude, lng: longitude });
×
237
        }
238

239
        // Store event handlers for proper cleanup
240
        let planTripHandler, tabSwitchHandler;
×
241

242
        onMount(async () => {
×
243
                await initMap();
×
244
                isMapLoaded.set(true);
×
245
                if (browser) {
×
246
                        const darkMode = document.documentElement.classList.contains('dark');
×
247

248
                        // Store handlers for cleanup
249
                        planTripHandler = () => {
×
250
                                isTripPlanModeActive = true;
×
251
                        };
252
                        tabSwitchHandler = () => {
×
253
                                isTripPlanModeActive = false;
×
254
                        };
255

256
                        window.addEventListener('planTripTabClicked', planTripHandler);
×
257
                        window.addEventListener('tabSwitched', tabSwitchHandler);
×
258

259
                        const event = new CustomEvent('themeChange', { detail: { darkMode } });
×
260
                        window.dispatchEvent(event);
×
261
                }
262
        });
263

264
        onDestroy(() => {
×
265
                if (browser) {
×
266
                        window.removeEventListener('themeChange', handleThemeChange);
×
267

268
                        if (planTripHandler) window.removeEventListener('planTripTabClicked', planTripHandler);
×
269
                        if (tabSwitchHandler) window.removeEventListener('tabSwitched', tabSwitchHandler);
×
270
                }
271

NEW
272
                if (modeChangeTimeout) {
×
NEW
273
                        clearTimeout(modeChangeTimeout);
×
274
                }
275

UNCOV
276
                clearAllMarkers();
×
277

278
                allStopsMap.clear();
×
279
                stopsCache.clear();
×
280
        });
281
</script>
282

283
<div class="map-container">
284
        <div id="map" bind:this={mapElement}></div>
×
285

286
        {#if selectedTrip && showRouteMap}
×
NEW
287
                <RouteMap mapProvider={mapInstance} tripId={selectedTrip.tripId} currentSelectedStop={stop} />
×
288
        {/if}
289
</div>
290

291
<div class="controls">
292
        <LocationButton {handleLocationObtained} />
293
</div>
294

295
<style>
296
        .map-container {
297
                position: relative;
298
                height: 100%;
299
                width: 100%;
300
                z-index: 1;
301
        }
302
        #map {
303
                height: 100%;
304
                width: 100%;
305
        }
306
</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