Skip to content

Commit 0ad825a

Browse files
open the station menu automatically at the route's pickup station
When a route involves picking up a bike (or leads to a station), the station menu now opens on its own once the user gets within unlocking distance of that station, so unlocking a bike doesn't require tapping it — which used to replace the route. It opens once per approach, so dismissing it sticks. Tapping a station that is already part of the route (pickup, dropoff or the station destination) now only opens its menu and centers it, instead of rerouting to that station.
1 parent 405c4d2 commit 0ad825a

3 files changed

Lines changed: 68 additions & 13 deletions

File tree

src/lib/components/Map.svelte

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,19 +51,31 @@
5151
const feature = e.features[0] as GeoJSON.Feature<GeoJSON.Point>;
5252
const props = feature.properties as { serialNumber: string, name: string, bikes: number };
5353
selectedStation.set(props.serialNumber);
54-
routeDestination.set({
55-
type: 'station',
56-
lat: feature.geometry.coordinates[1],
57-
lng: feature.geometry.coordinates[0],
58-
name: props.name,
59-
stationSerial: props.serialNumber,
60-
});
54+
// Tapping a station that's already part of the route (e.g. to unlock a
55+
// bike at the pickup station) only opens its menu — it must not replace
56+
// the route with a route to that station
57+
const route = get(currentRoute);
58+
const partOfRoute = route != null && (
59+
route.startStationSerial === props.serialNumber ||
60+
route.endStationSerial === props.serialNumber ||
61+
(route.destination.type === 'station' && route.destination.stationSerial === props.serialNumber)
62+
);
63+
if (!partOfRoute) {
64+
routeDestination.set({
65+
type: 'station',
66+
lat: feature.geometry.coordinates[1],
67+
lng: feature.geometry.coordinates[0],
68+
name: props.name,
69+
stationSerial: props.serialNumber,
70+
});
71+
}
6172
await tick();
6273
await tick();
6374
// With no active trip the camera moves once, when the computed route is
64-
// fit to the view; during a trip (or without a location, when no route
65-
// can be computed) no fit happens, so center the station instead
66-
if (get(currentTrip) !== null || get(currentPos) === null) {
75+
// fit to the view; during a trip, without a location (when no route can
76+
// be computed) or when the route is kept, no fit happens, so center the
77+
// station instead
78+
if (get(currentTrip) !== null || get(currentPos) === null || partOfRoute) {
6779
map.flyTo({
6880
center: feature.geometry.coordinates as [number, number],
6981
padding: { top: topPadding, bottom: Math.min(bottomPadding, window.innerHeight / 2), left: leftPadding },

src/lib/routing.test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { beforeAll, describe, expect, it, vi } from 'vitest';
22
import { computeRoute, currentRoute, routeDestination } from '$lib/routing';
33
import { currentPos } from '$lib/location';
4-
import { stations } from '$lib/map.svelte';
4+
import { selectedStation, stations } from '$lib/map.svelte';
55
import { ROUTING_API_URL } from '$lib/constants';
66
import { get } from 'svelte/store';
77

@@ -82,4 +82,22 @@ describe.skipIf(!serverReachable)('computeRoute', () => {
8282
expect(route!.legs[route!.legs.length - 1].mode).toBe('bike');
8383
expect(route!.endStationSerial).toBe('marques');
8484
});
85-
});
85+
86+
it('should open the station menu when reaching the pickup station', async () => {
87+
selectedStation.set(null);
88+
currentPos.set({ coords: { latitude: 38.7075, longitude: -9.1440, accuracy: 5, altitude: null, altitudeAccuracy: null, speed: null, heading: null }, timestamp: Date.now() });
89+
routeDestination.set({ type: 'location', lat: 38.7700, lng: -9.0950, name: 'Parque das Nações' });
90+
await vi.waitFor(() => expect(get(currentRoute)?.startStationSerial).toBe('sodre'), { timeout: 15000 });
91+
expect(get(selectedStation)).toBeNull();
92+
93+
// Walk up to the pickup station
94+
currentPos.set({ coords: { latitude: 38.7064, longitude: -9.1450, accuracy: 5, altitude: null, altitudeAccuracy: null, speed: null, heading: null }, timestamp: Date.now() });
95+
expect(get(selectedStation)).toBe('sodre');
96+
97+
// Dismissing it sticks: another position update nearby must not reopen it
98+
selectedStation.set(null);
99+
currentPos.set({ coords: { latitude: 38.7065, longitude: -9.1449, accuracy: 5, altitude: null, altitudeAccuracy: null, speed: null, heading: null }, timestamp: Date.now() });
100+
expect(get(selectedStation)).toBeNull();
101+
routeDestination.set(null);
102+
});
103+
});

src/lib/routing.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { get, writable } from 'svelte/store';
22
import { currentPos } from '$lib/location';
33
import { currentTrip } from '$lib/trip';
4-
import { stations, type StationInfo } from '$lib/map.svelte';
4+
import { LOCK_DISTANCE_m } from '$lib/constants';
5+
import { selectedStation, stations, type StationInfo } from '$lib/map.svelte';
56
import { distanceBetweenCoords } from '$lib/utils';
67
import { errorMessages } from '$lib/ui.svelte';
78
import { t } from '$lib/translations';
@@ -298,7 +299,29 @@ export function clearRouteDestination() {
298299
routeDestination.set(null);
299300
}
300301

302+
/** The station the route heads to for picking up a bike — the pickup station,
303+
* or a station destination itself when walking straight to one */
304+
export function routePickupStationSerial(route: PlannedRoute|null): string|null {
305+
if (!route) return null;
306+
return route.startStationSerial ?? (route.destination.type === 'station' ? route.destination.stationSerial : null);
307+
}
308+
309+
// Open the station menu automatically when the user reaches the station where
310+
// they will pick up a bike, so they don't need to tap it to unlock one
311+
let autoOpenedStation: string|null = null;
312+
function autoOpenStationMenu(pos: Coord) {
313+
if (get(currentTrip) !== null) return;
314+
const serial = routePickupStationSerial(get(currentRoute));
315+
if (!serial || autoOpenedStation === serial) return;
316+
const station = stations.value.find(s => s.serialNumber === serial);
317+
if (!station) return;
318+
if (distanceBetweenCoords(pos.lat, pos.lng, station.latitude, station.longitude) * 1000 > LOCK_DISTANCE_m) return;
319+
autoOpenedStation = serial; // only once per approach, so dismissing it sticks
320+
if (get(selectedStation) == null) selectedStation.set(serial);
321+
}
322+
301323
routeDestination.subscribe(destination => {
324+
autoOpenedStation = null;
302325
if (!destination) {
303326
currentRoute.set(null);
304327
return;
@@ -323,6 +346,8 @@ currentPos.subscribe(pos => {
323346
return;
324347
}
325348

349+
autoOpenStationMenu({ lat: pos.coords.latitude, lng: pos.coords.longitude });
350+
326351
const route = get(currentRoute);
327352
if (route) {
328353
const movedM = distanceBetweenCoords(pos.coords.latitude, pos.coords.longitude, route.origin.lat, route.origin.lng) * 1000;

0 commit comments

Comments
 (0)