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

OneBusAway / wayfinder / 29559896664

17 Jul 2026 06:23AM UTC coverage: 84.483%. First build
29559896664

Pull #560

github

web-flow
Merge cb0fa7101 into 5894b956a
Pull Request #560: Stop bottom sheet at all viewport widths + calmer map route labels

2267 of 2502 branches covered (90.61%)

Branch coverage included in aggregate %.

315 of 342 new or added lines in 14 files covered. (92.11%)

13593 of 16271 relevant lines covered (83.54%)

4.73 hits per line

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

0.0
/src/lib/Provider/GoogleMapProvider.svelte.js
1
import { loadGoogleMapsLibrary, createMap, nightModeStyles } from '$lib/googleMaps';
×
2
import StopMarker from '$components/map/StopMarker.svelte';
3
import { faBus } from '@fortawesome/free-solid-svg-icons';
4
import {
5
        RouteType,
6
        routePriorities,
7
        prioritizedRouteTypeForDisplay,
8
        SHOW_ROUTE_LABELS_AT_ZOOM
9
} from '$config/routeConfig';
10
import { COLORS } from '$lib/colors';
11
import PopupContent from '$components/map/PopupContent.svelte';
12
import ContextMenuPopup from '$components/map/ContextMenuPopup.svelte';
13
import VehiclePopupContent from '$components/map/VehiclePopupContent.svelte';
14
import { createVehicleIconSvg, iconHeight, iconWidth } from '$lib/MapHelpers/generateVehicleIcon';
15
import { animateMarkerTo, cancelMarkerAnimation } from '$lib/MapHelpers/animateMarker';
16
import TripPlanPinMarker from '$components/trip-planner/tripPlanPinMarker.svelte';
17
import { mount, unmount } from 'svelte';
18
import { buildVehiclePopupData } from '$lib/vehicleUtils';
19

20
export default class GoogleMapProvider {
×
21
        constructor(apiKey, handleStopMarkerSelect) {
×
22
                this.apiKey = apiKey;
×
23
                this.map = null;
×
24
                this.globalInfoWindow = null;
×
25
                this.popupContentComponent = null;
×
26
                this.stopsMap = new Map();
×
27
                this.stopMarkers = [];
×
28
                this.vehicleMarkers = [];
×
29
                this.markersMap = new Map();
×
30
                this.handleStopMarkerSelect = handleStopMarkerSelect;
×
31
                this.polylines = []; // Track all polylines for easy cleanup
×
NEW
32
                this.showStopsRoutesAtZoom = SHOW_ROUTE_LABELS_AT_ZOOM;
×
33
                this.routeLabelsVisible = false;
×
34
                this.contextMenuInfoWindow = null;
×
35
                this.contextMenuComponent = null;
×
36
        }
37

38
        async initMap(element, options) {
×
39
                // Load the Google Maps library
40
                loadGoogleMapsLibrary(this.apiKey);
×
41

42
                // Wait for the Google Maps API to be fully loaded
43
                await new Promise((resolve) => {
×
44
                        const checkGoogleMaps = () => {
×
45
                                if (window.google && window.google.maps) {
×
46
                                        resolve();
×
47
                                } else {
48
                                        setTimeout(checkGoogleMaps, 100);
×
49
                                }
50
                        };
51
                        checkGoogleMaps();
×
52
                });
53

54
                // Use the createMap function from googleMaps.js
55
                this.map = await createMap({
×
56
                        element,
×
57
                        lat: options.lat,
×
58
                        lng: options.lng
×
59
                });
60

61
                // Update route labels (on stops) visibility on zoom changes
62
                this.map.addListener('zoom_changed', () => {
×
63
                        this.updateMarkersRouteLabelVisibility();
×
64
                });
65
        }
66

67
        eventListeners(mapInstance, debouncedLoadMarkers) {
×
68
                mapInstance.addListener('dragend', debouncedLoadMarkers);
×
69
                mapInstance.addListener('zoom_changed', debouncedLoadMarkers);
×
70
                mapInstance.addListener('center_changed', debouncedLoadMarkers);
×
71
        }
72

73
        addMarker(options) {
×
74
                try {
75
                        if (this.markersMap.has(options.stop.id)) {
×
76
                                return this.markersMap.get(options.stop.id);
×
77
                        }
78

79
                        let icon = options.icon || faBus;
×
80

81
                        if (!options.icon && options.stop.routes && options.stop.routes.length > 0) {
×
82
                                const routeTypes = options.stop.routes.map((r) => r.type);
×
83
                                let prioritizedType = RouteType.UNKNOWN;
×
84

85
                                for (const priority of routePriorities) {
×
86
                                        if (routeTypes.includes(priority)) {
×
87
                                                prioritizedType = priority;
×
88
                                                break;
89
                                        }
90
                                }
91

92
                                icon = prioritizedRouteTypeForDisplay(prioritizedType);
×
93
                        }
94

95
                        const container = document.createElement('div');
×
96
                        document.body.appendChild(container);
×
97

98
                        const props = $state({
×
99
                                stop: options.stop,
×
100
                                icon: icon,
×
101
                                onClick: options.onClick,
×
102
                                isHighlighted: options.isHighlighted ?? false,
×
103
                                showRoutesLabel: this.map.getZoom() >= this.showStopsRoutesAtZoom
×
104
                        });
105

106
                        const marker = mount(StopMarker, {
×
107
                                target: container,
×
108
                                props
×
109
                        });
110

111
                        this.markersMap.set(options.stop.id, marker);
×
112

113
                        const overlay = new google.maps.OverlayView();
×
114
                        overlay.onAdd = function () {
×
115
                                this.getPanes().overlayMouseTarget.appendChild(container);
×
116
                        };
117
                        overlay.draw = function () {
×
118
                                const projection = this.getProjection();
×
119
                                const position = projection.fromLatLngToDivPixel(options.position);
×
120
                                container.style.left = position.x - 20 + 'px';
×
121
                                container.style.top = position.y - 20 + 'px';
×
122
                                container.style.position = 'absolute';
×
123
                                container.style.zIndex = '1000';
×
124
                        };
125
                        overlay.onRemove = function () {
×
126
                                container.parentNode.removeChild(container);
×
127
                        };
128
                        overlay.setMap(this.map);
×
129

130
                        const markerObj = { overlay, element: container, props };
×
131
                        this.markersMap.set(options.stop.id, markerObj);
×
132

133
                        return markerObj;
×
134
                } catch (error) {
×
135
                        console.error('Error adding marker:', error);
×
136
                        return null;
×
137
                }
138
        }
139

140
        removeMarker(markerObj) {
×
141
                if (!markerObj) return;
×
142

143
                if (markerObj.marker) {
×
144
                        markerObj.marker.setMap(null);
×
145
                }
146
                if (markerObj.overlay) {
×
147
                        markerObj.overlay.setMap(null);
×
148
                }
149
                if (markerObj.element && markerObj.element.parentNode) {
×
150
                        markerObj.element.parentNode.removeChild(markerObj.element);
×
151
                }
152

153
                for (const [stopId, storedMarker] of this.markersMap.entries()) {
×
154
                        if (storedMarker === markerObj) {
×
155
                                this.markersMap.delete(stopId);
×
156
                                break;
157
                        }
158
                }
159
        }
160

161
        hasMarker(stopId) {
×
162
                return this.markersMap.has(stopId);
×
163
        }
164

165
        getMarker(stopId) {
×
166
                return this.markersMap.get(stopId);
×
167
        }
168

169
        clearAllStopMarkers() {
×
170
                if (!this.map) return;
×
171

172
                // Clear the main stop markers
173
                for (const marker of this.markersMap.values()) {
×
174
                        this.removeMarker(marker);
×
175
                }
176
                this.markersMap.clear();
×
177
        }
178

179
        updateMarkersRouteLabelVisibility() {
×
180
                if (!this.map) return;
×
181

182
                const shouldShow = this.map.getZoom() >= this.showStopsRoutesAtZoom;
×
183

184
                if (this.routeLabelsVisible === shouldShow) return;
×
185

186
                this.routeLabelsVisible = shouldShow;
×
187

188
                // Batch update all markers
189
                for (const marker of this.markersMap.values()) {
×
190
                        if (marker?.props) {
×
191
                                marker.props.showRoutesLabel = shouldShow;
×
192
                        }
193
                }
194
        }
195

196
        addStopRouteMarker(stop, stopTime = null) {
×
197
                const marker = new google.maps.Marker({
×
198
                        position: { lat: stop.lat, lng: stop.lon },
×
199
                        map: this.map,
×
200
                        title: stop.name,
×
201
                        icon: {
×
202
                                path: google.maps.SymbolPath.CIRCLE,
×
203
                                scale: 5,
×
204
                                fillColor: '#FFFFFF',
×
205
                                fillOpacity: 1,
×
206
                                strokeWeight: 1,
×
207
                                strokeColor: '#000000'
×
208
                        }
209
                });
210

211
                this.stopsMap.set(stop.id, stop);
×
212

213
                marker.addListener('click', () => this.openStopMarker(stop, stopTime));
×
214

215
                this.markersMap.set(stop.id, marker);
×
216
                this.stopMarkers.push(marker);
×
217
        }
218

219
        openStopMarker(stop, stopTime = null) {
×
220
                this.closeContextMenu();
×
221

222
                if (this.globalInfoWindow) {
×
223
                        this.globalInfoWindow.close();
×
224
                }
225

226
                if (this.popupContentComponent) {
×
227
                        unmount(this.popupContentComponent);
×
228
                }
229

230
                const popupContainer = document.createElement('div');
×
231

232
                this.popupContentComponent = mount(PopupContent, {
×
233
                        target: popupContainer,
×
234
                        props: {
×
235
                                stopName: stop.name,
×
236
                                arrivalTime: stopTime ? stopTime.arrivalTime : null,
×
237
                                handleStopMarkerSelect: () => this.handleStopMarkerSelect(stop)
×
238
                        }
239
                });
240

241
                this.globalInfoWindow = new google.maps.InfoWindow({
×
242
                        content: popupContainer
×
243
                });
244

245
                this.globalInfoWindow.open(this.map, this.markersMap.get(stop.id));
×
246
        }
247

248
        updatePopupContent(stop, arrivalTime = null) {
×
249
                if (this.popupContentComponent && this.globalInfoWindow) {
×
250
                        // Unmount and remount the component with new props
251
                        unmount(this.popupContentComponent);
×
252

253
                        const popupContainer = this.globalInfoWindow.getContent();
×
254

255
                        this.popupContentComponent = mount(PopupContent, {
×
256
                                target: popupContainer,
×
257
                                props: {
×
258
                                        stopName: stop.name,
×
259
                                        arrivalTime: arrivalTime,
×
260
                                        handleStopMarkerSelect: () => this.handleStopMarkerSelect(stop)
×
261
                                }
262
                        });
263
                }
264
        }
265

266
        highlightMarker(stopId) {
×
267
                const marker = this.markersMap.get(stopId);
×
268
                if (!marker) return;
×
269

270
                marker.props.isHighlighted = true;
×
271
        }
272

273
        unHighlightMarker(stopId) {
×
274
                const marker = this.markersMap.get(stopId);
×
275
                if (!marker) return;
×
276

277
                marker.props.isHighlighted = false;
×
278
        }
279

280
        removeStopMarkers() {
×
281
                this.stopMarkers.forEach((marker) => {
×
282
                        marker.setMap(null);
×
283
                });
284
                this.stopMarkers = [];
×
285
        }
286

287
        addPinMarker(position, text) {
×
288
                const container = document.createElement('div');
×
289
                document.body.appendChild(container);
×
290

291
                mount(TripPlanPinMarker, {
×
292
                        target: container,
×
293
                        props: {
×
294
                                text: text
×
295
                        }
296
                });
297

298
                const overlay = new google.maps.OverlayView();
×
299

300
                overlay.onAdd = function () {
×
301
                        this.getPanes().overlayMouseTarget.appendChild(container);
×
302
                };
303

304
                overlay.draw = function () {
×
305
                        const projection = this.getProjection();
×
306
                        const pos = projection.fromLatLngToDivPixel(
×
307
                                new google.maps.LatLng(position.lat, position.lng)
×
308
                        );
309
                        container.style.left = `${pos.x - 16}px`;
×
310
                        container.style.top = `${pos.y - 50}px`;
×
311
                        container.style.position = 'absolute';
×
312
                        container.style.zIndex = '1000';
×
313
                };
314

315
                overlay.onRemove = function () {
×
316
                        container.parentNode.removeChild(container);
×
317
                };
318

319
                overlay.setMap(this.map);
×
320

321
                return { overlay, element: container };
×
322
        }
323

324
        removePinMarker(marker) {
×
325
                if (!marker) {
×
326
                        return;
327
                }
328

329
                if (marker.overlay) {
×
330
                        marker.overlay.setMap(null);
×
331
                }
332

333
                if (marker.element && marker.element.parentNode) {
×
334
                        marker.element.parentNode.removeChild(marker.element);
×
335
                }
336
        }
337

338
        addVehicleMarker(vehicle, activeTrip, routeType) {
×
339
                if (!this.map) return null;
×
340

341
                let color;
×
342
                if (!vehicle.predicted) {
×
343
                        color = COLORS.VEHICLE_REAL_TIME_OFF;
×
344
                }
345

346
                const vehicleIconSvg = createVehicleIconSvg(vehicle?.orientation, color, routeType);
×
347
                const icon = {
×
348
                        url: `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(vehicleIconSvg)}`,
×
349
                        scaledSize: new google.maps.Size(iconWidth, iconHeight),
×
350
                        anchor: new google.maps.Point(iconWidth / 2, iconHeight / 2)
×
351
                };
352

353
                const marker = new google.maps.Marker({
×
354
                        position: { lat: vehicle.position.lat, lng: vehicle.position.lon },
×
355
                        map: this.map,
×
356
                        icon: icon,
×
357
                        zIndex: 1000
×
358
                });
359

360
                this.vehicleMarkers.push(marker);
×
361

362
                const vehicleData = $state(buildVehiclePopupData(vehicle, activeTrip, this.stopsMap));
×
363

364
                marker.vehicleData = vehicleData;
×
365

366
                const popupContainer = document.createElement('div');
×
367
                marker.popupComponent = mount(VehiclePopupContent, {
×
368
                        target: popupContainer,
×
369
                        props: marker.vehicleData
×
370
                });
371

372
                marker.infoWindow = new google.maps.InfoWindow({
×
373
                        content: popupContainer
×
374
                });
375

376
                marker.addListener('click', () => {
×
377
                        marker.infoWindow.open(this.map, marker);
×
378
                });
379

380
                return marker;
×
381
        }
382

383
        updateVehicleMarker(marker, vehicleStatus, activeTrip, routeType) {
×
384
                if (!this.map || !marker) return;
×
385

386
                const current = marker.getPosition();
×
387
                animateMarkerTo(
×
388
                        marker,
×
389
                        { lat: current.lat(), lng: current.lng() },
×
390
                        { lat: vehicleStatus.position.lat, lng: vehicleStatus.position.lon },
×
391
                        (lat, lng) => marker.setPosition({ lat, lng }),
×
392
                        { routePaths: this._getRoutePaths() }
×
393
                );
394

395
                let color;
×
396
                if (!vehicleStatus.predicted) {
×
397
                        color = COLORS.VEHICLE_REAL_TIME_OFF;
×
398
                }
399

400
                const updatedIcon = createVehicleIconSvg(vehicleStatus.orientation, color, routeType);
×
401
                marker.setIcon({
×
402
                        url: `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(updatedIcon)}`,
×
403
                        scaledSize: new google.maps.Size(iconWidth, iconHeight),
×
404
                        anchor: new google.maps.Point(iconWidth / 2, iconHeight / 2)
×
405
                });
406

407
                Object.assign(
×
408
                        marker.vehicleData,
×
409
                        buildVehiclePopupData(vehicleStatus, activeTrip, this.stopsMap)
×
410
                );
411
        }
412

413
        removeVehicleMarker(marker) {
×
414
                cancelMarkerAnimation(marker);
×
415
                marker.setMap(null);
×
416
        }
417

418
        clearVehicleMarkers() {
×
419
                if (!this.map) return;
×
420

421
                for (const marker of this.vehicleMarkers) {
×
422
                        cancelMarkerAnimation(marker);
×
423
                        marker.setMap(null);
×
424
                }
425
                this.vehicleMarkers = [];
×
426
        }
427

428
        /**
429
         * Returns the currently drawn route shapes as plain coordinate arrays, used
430
         * to animate vehicles along the route instead of in a straight line.
431
         * @returns {Array<Array<{lat:number,lng:number}>>}
432
         */
433
        _getRoutePaths() {
×
434
                return this.polylines
×
435
                        .map((polyline) => polyline.getPath()?.getArray() ?? [])
×
436
                        .filter((points) => points.length >= 2)
×
437
                        .map((points) => points.map((ll) => ({ lat: ll.lat(), lng: ll.lng() })));
×
438
        }
439

440
        cleanupInfoWindow() {
×
441
                if (this.globalInfoWindow) {
×
442
                        this.globalInfoWindow.close();
×
443
                }
444
        }
445

446
        setCenter(latLng) {
×
447
                this.map.setCenter(latLng);
×
448
        }
449

450
        getCenter() {
×
451
                const center = this.map.getCenter();
×
452
                return { lat: center.lat(), lng: center.lng() };
×
453
        }
454

455
        addListener(event, callback) {
×
456
                this.map.addListener(event, callback);
×
457
        }
458

459
        setTheme(theme) {
×
460
                const styles = theme === 'dark' ? nightModeStyles() : null;
×
461
                this.map.setOptions({ styles });
×
462
        }
463

464
        addUserLocationMarker(latLng) {
×
465
                new google.maps.Marker({
×
466
                        map: this.map,
×
467
                        position: latLng,
×
468
                        title: 'Your Location',
×
469
                        icon: {
×
470
                                path: google.maps.SymbolPath.CIRCLE,
×
471
                                scale: 8,
×
472
                                fillColor: '#007BFF',
×
473
                                fillOpacity: 1,
×
474
                                strokeWeight: 2,
×
475
                                strokeColor: '#FFFFFF'
×
476
                        }
477
                });
478
        }
479

480
        /**
481
         * Creates a polyline from an encoded shape, returning `null` when the shape
482
         * can't be decoded (uniform with the OSM provider).
483
         *
484
         * Contract note: this method is async — it returns a `Promise<Polyline|null>`
485
         * because it lazy-loads the Google geometry library — whereas the OSM
486
         * provider's createPolyline is synchronous (`Polyline|null`). Callers that
487
         * need provider-agnostic behavior should `await` the result and guard
488
         * against `null`.
489
         */
490
        async createPolyline(shape, options = {}) {
×
491
                // Backward compat: old callers pass a boolean as the second arg
492
                if (typeof options === 'boolean') {
×
493
                        options = { withArrow: options };
×
494
                }
495

496
                const withArrow = options.withArrow !== undefined ? options.withArrow : true;
×
497

498
                await window.google.maps.importLibrary('geometry');
×
499

500
                let decodedPath;
×
501
                try {
502
                        decodedPath = google.maps.geometry.encoding.decodePath(shape);
×
503
                } catch (error) {
×
504
                        console.error('Failed to decode polyline:', error?.message);
×
505
                        return null;
×
506
                }
507
                if (!decodedPath || decodedPath.length === 0) {
×
508
                        console.error('Failed to decode polyline:', shape);
×
509
                        return null;
×
510
                }
511
                const path = decodedPath.map((point) => ({ lat: point.lat(), lng: point.lng() }));
×
512

513
                const polylineOptions = {
×
514
                        path,
×
515
                        geodesic: true,
×
516
                        strokeColor: options.color || COLORS.POLYLINE,
×
517
                        strokeOpacity: options.dashArray ? 0 : (options.opacity ?? 1.0),
×
518
                        strokeWeight: options.weight || 5
×
519
                };
520

521
                const icons = [];
×
522

523
                // Dashed line for walking legs
524
                if (options.dashArray) {
×
525
                        icons.push({
×
526
                                icon: {
×
527
                                        path: 'M 0,-1 0,1',
×
528
                                        strokeOpacity: options.opacity ?? 1.0,
×
529
                                        strokeColor: options.color || COLORS.POLYLINE,
×
530
                                        scale: options.weight || 5
×
531
                                },
532
                                offset: '0',
×
533
                                repeat: '20px'
×
534
                        });
535
                }
536

537
                if (withArrow) {
×
538
                        const arrowSymbol = {
×
539
                                path: google.maps.SymbolPath.FORWARD_CLOSED_ARROW,
×
540
                                scale: 2,
×
541
                                strokeColor: COLORS.POLYLINE_ARROW_STROKE,
×
542
                                strokeWeight: 3
×
543
                        };
544

545
                        icons.push({
×
546
                                icon: arrowSymbol,
×
547
                                offset: '100%',
×
548
                                repeat: '50px'
×
549
                        });
550
                }
551

552
                if (icons.length > 0) {
×
553
                        polylineOptions.icons = icons;
×
554
                }
555

556
                const polyline = new window.google.maps.Polyline(polylineOptions);
×
557

558
                polyline.setMap(this.map);
×
559

560
                this.polylines.push(polyline);
×
561

562
                return polyline;
×
563
        }
564

565
        async removePolyline(polyline) {
×
566
                if (polyline && polyline.setMap) {
×
567
                        polyline.setMap(null);
×
568
                }
569

570
                // Remove from tracking array
571
                const index = this.polylines.indexOf(polyline);
×
572
                if (index > -1) {
×
573
                        this.polylines.splice(index, 1);
×
574
                }
575

576
                return null;
×
577
        }
578

579
        /**
580
         * Clears all polylines from the map and resets the tracking array.
581
         * This provides a centralized way to manage polyline cleanup for better state management.
582
         */
583
        clearAllPolylines() {
×
584
                // Remove all polylines from the map
585
                this.polylines.forEach((polyline) => {
×
586
                        if (polyline && polyline.setMap) {
×
587
                                polyline.setMap(null);
×
588
                        }
589
                });
590

591
                this.polylines = [];
×
592
        }
593

594
        /**
595
         * Returns the number of currently active polylines on the map.
596
         * Useful for debugging and state management.
597
         */
598
        getPolylinesCount() {
×
599
                return this.polylines.length;
×
600
        }
601

602
        panTo(lat, lng) {
×
603
                this.map.panTo({ lat, lng });
×
604
        }
605

606
        // Google Maps repositions instantly here (no zoom animation), so its native
607
        // polylines never desync. `_options` mirrors the OSM provider's signature
608
        // (e.g. `{ animate }`) but has no effect here.
609
        // eslint-disable-next-line no-unused-vars
610
        flyTo(lat, lng, zoom = 15, _options = {}) {
×
611
                this.map.setZoom(zoom);
×
612
                this.map.setCenter({ lat, lng });
×
613
        }
614
        setZoom(zoom) {
×
615
                this.map.setZoom(zoom);
×
616
        }
617

618
        /**
619
         * Fits the map view to the bounds of all currently drawn polylines so the
620
         * full route is centered and visible. Returns true when a fit was applied.
621
         * @param {{ padding?: number, maxZoom?: number }} [options]
622
         * @returns {Promise<boolean>} resolves once the view has settled
623
         */
624
        async fitToPolylines(options = {}) {
×
625
                if (!this.map || this.polylines.length === 0) return false;
×
626

627
                const bounds = new window.google.maps.LatLngBounds();
×
628
                this.polylines.forEach((polyline) => {
×
629
                        polyline.getPath().forEach((latLng) => bounds.extend(latLng));
×
630
                });
631

632
                if (bounds.isEmpty()) return false;
×
633

634
                const maxZoom = options.maxZoom ?? 16;
×
635

636
                return new Promise((resolve) => {
×
637
                        let settled = false;
×
638
                        let idleListener = null;
×
639
                        // fitBounds has no maxZoom option on Google Maps, so clamp once the
640
                        // view settles to avoid zooming in too far on short routes. Resolve
641
                        // here so callers can reveal stop markers in sync with the fit.
642
                        const finish = () => {
×
643
                                if (settled) return;
×
644
                                settled = true;
×
645
                                // Drop the idle listener in case we resolved via the timeout
646
                                // safety net below before `idle` ever fired.
647
                                if (idleListener) window.google.maps.event.removeListener(idleListener);
×
648
                                if (this.map.getZoom() > maxZoom) {
×
649
                                        this.map.setZoom(maxZoom);
×
650
                                }
651
                                resolve(true);
×
652
                        };
653

654
                        idleListener = window.google.maps.event.addListenerOnce(this.map, 'idle', finish);
×
655
                        // Safety net: Google Maps does not guarantee an `idle` event when
656
                        // fitBounds produces no viewport change, so resolve anyway after a
657
                        // short delay to avoid hanging callers that await this.
658
                        setTimeout(finish, 1000);
×
659

660
                        this.map.fitBounds(bounds, options.padding ?? 50);
×
661
                });
662
        }
663

664
        enableContextMenu() {
×
665
                if (!this.map) return;
×
666
                this.map.addListener('rightclick', (e) => {
×
667
                        this.showContextMenu(e.latLng);
×
668
                });
669
        }
670

671
        showContextMenu(latLng) {
×
672
                this.closeContextMenu();
×
673

674
                const lat = latLng.lat();
×
675
                const lng = latLng.lng();
×
676

677
                const popupContainer = document.createElement('div');
×
678

679
                const dispatchAndClose = (type) => {
×
680
                        window.dispatchEvent(
×
681
                                new CustomEvent('contextMenuTripPlan', {
×
682
                                        detail: { type, lat, lng }
×
683
                                })
684
                        );
685
                        this.closeContextMenu();
×
686
                };
687

688
                this.contextMenuComponent = mount(ContextMenuPopup, {
×
689
                        target: popupContainer,
×
690
                        props: {
×
691
                                onStartHere: () => dispatchAndClose('from'),
×
692
                                onEndHere: () => dispatchAndClose('to')
×
693
                        }
694
                });
695

696
                if (this.globalInfoWindow) {
×
697
                        this.globalInfoWindow.close();
×
698
                }
699

700
                this.contextMenuInfoWindow = new google.maps.InfoWindow({
×
701
                        content: popupContainer,
×
702
                        position: { lat, lng }
×
703
                });
704

705
                this.contextMenuInfoWindow.addListener('closeclick', () => {
×
706
                        if (this.contextMenuComponent) {
×
707
                                unmount(this.contextMenuComponent);
×
708
                                this.contextMenuComponent = null;
×
709
                        }
710
                });
711

712
                this.contextMenuInfoWindow.open(this.map);
×
713
        }
714

715
        closeContextMenu() {
×
716
                if (this.contextMenuInfoWindow) {
×
717
                        this.contextMenuInfoWindow.close();
×
718
                        if (this.contextMenuComponent) {
×
719
                                unmount(this.contextMenuComponent);
×
720
                                this.contextMenuComponent = null;
×
721
                        }
722
                        this.contextMenuInfoWindow = null;
×
723
                }
724
        }
725

726
        getBoundingBox() {
×
727
                const bounds = this.map.getBounds();
×
728
                const ne = bounds.getNorthEast();
×
729
                const sw = bounds.getSouthWest();
×
730
                return {
731
                        north: ne.lat(),
×
732
                        east: ne.lng(),
×
733
                        south: sw.lat(),
×
734
                        west: sw.lng()
×
735
                };
736
        }
737
}
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