Skip to content

Commit 405c4d2

Browse files
committed
Clip routes on the client side.
1 parent 450e780 commit 405c4d2

5 files changed

Lines changed: 272 additions & 2 deletions

File tree

bun.lock

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@
6565
"@fontsource/roboto-mono": "^5.2.6",
6666
"@mauricewegner/capacitor-navigation-bar": "^2.0.3",
6767
"@tabler/icons-svelte": "^3.34.1",
68+
"@turf/distance": "^7.3.5",
69+
"@turf/helpers": "^7.3.5",
70+
"@turf/nearest-point-on-line": "^7.3.5",
6871
"@types/suncalc": "^1.9.2",
6972
"capacitor-plugin-safe-area": "^2.0.6",
7073
"maplibre-gl": "^4.7.1",

src/lib/components/Map.svelte

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { getMapStyle } from '$lib/map-style';
55
import { addLayers, following, loadImages, selectedStation, setSourceData, stations } from '$lib/map.svelte';
66
import { currentRoute, routeDestination, type PlannedRoute } from '$lib/routing';
7+
import { clipRouteAtProjection, emptyRouteClippingState, projectPositionOntoRoute, type RouteClippingState } from '$lib/route-clipping';
78
import { reverseGeocode } from '$lib/geocoding';
89
import { theme } from '$lib/theme';
910
import { currentTrip, type ActiveTrip } from '$lib/trip';
@@ -33,6 +34,7 @@
3334
let mapLoaded = $state(false);
3435
let ready = $derived(mapLoaded && !loading && stations.value.length != 0);
3536
let blurred = $state(true);
37+
let routeClippingState: RouteClippingState = emptyRouteClippingState();
3638
3739
$effect(() => {
3840
if (ready) setTimeout(() => blurred = false, 500);
@@ -131,15 +133,23 @@
131133
});
132134
}
133135
}
136+
applyRouteData(get(currentRoute), pos);
134137
});
135138
136-
function applyRouteData(route: PlannedRoute|null) {
139+
function applyRouteData(route: PlannedRoute|null, pos = get(currentPos)) {
137140
const src = map.getSource<maplibregl.GeoJSONSource>('route');
138141
const destSrc = map.getSource<maplibregl.GeoJSONSource>('route-destination');
139142
if (src == null || destSrc == null) return;
143+
if (route && pos?.coords) {
144+
routeClippingState = projectPositionOntoRoute(route, {
145+
lat: pos.coords.latitude,
146+
lng: pos.coords.longitude,
147+
}, routeClippingState);
148+
}
149+
const displayLegs = route ? clipRouteAtProjection(route, routeClippingState.accepted) : [];
140150
src.setData({
141151
type: 'FeatureCollection',
142-
features: route?.legs.map(leg => ({
152+
features: displayLegs.map(leg => ({
143153
type: 'Feature' as const,
144154
properties: { mode: leg.mode },
145155
geometry: {
@@ -204,6 +214,7 @@
204214
// Zoom to the full route whenever a destination is picked, unless riding
205215
// (keep following the user)
206216
currentRoute.subscribe(route => {
217+
routeClippingState = emptyRouteClippingState();
207218
if (!mapLoaded) return;
208219
applyRouteData(route);
209220
if (!route) {
@@ -217,6 +228,7 @@
217228
});
218229
219230
routeDestination.subscribe(destination => {
231+
if (!destination) routeClippingState = emptyRouteClippingState();
220232
if (!mapLoaded) {
221233
pendingFit = destination != null;
222234
return;

src/lib/route-clipping.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, expect, it } from 'vitest';
2+
import {
3+
clipRouteAtProjection,
4+
emptyRouteClippingState,
5+
projectPositionOntoRoute,
6+
ROUTE_GLOBAL_REMATCH_READINGS,
7+
} from '$lib/route-clipping';
8+
import type { PlannedRoute, RouteLeg } from '$lib/routing';
9+
10+
const destination = { type: 'location' as const, lat: 0, lng: 0 };
11+
12+
function route(legs: RouteLeg[]): PlannedRoute {
13+
return {
14+
legs,
15+
totalDistance: 0,
16+
totalDuration: 0,
17+
startStationSerial: null,
18+
endStationSerial: null,
19+
origin: { lat: 0, lng: 0 },
20+
destination,
21+
computedAt: 0,
22+
};
23+
}
24+
25+
function leg(mode: RouteLeg['mode'], coordinates: [number, number][]): RouteLeg {
26+
return { mode, coordinates, distance: 0, duration: 0 };
27+
}
28+
29+
describe('route clipping', () => {
30+
it('clips precisely within a sparse segment', () => {
31+
const planned = route([leg('foot', [[0, 0], [0.002, 0]])]);
32+
const state = projectPositionOntoRoute(planned, { lng: 0.001, lat: 0.00001 }, emptyRouteClippingState());
33+
const clipped = clipRouteAtProjection(planned, state.accepted);
34+
expect(clipped[0].coordinates[0][0]).toBeCloseTo(0.001);
35+
expect(clipped[0].coordinates[0][1]).toBeCloseTo(0);
36+
expect(clipped[0].coordinates.at(-1)).toEqual([0.002, 0]);
37+
});
38+
39+
it('removes completed legs and preserves future leg modes', () => {
40+
const planned = route([
41+
leg('foot', [[0, 0], [0.001, 0]]),
42+
leg('bike', [[0.001, 0], [0.003, 0]]),
43+
leg('foot', [[0.003, 0], [0.004, 0]]),
44+
]);
45+
const state = projectPositionOntoRoute(planned, { lng: 0.002, lat: 0 }, emptyRouteClippingState());
46+
const clipped = clipRouteAtProjection(planned, state.accepted);
47+
expect(clipped.map(l => l.mode)).toEqual(['bike', 'foot']);
48+
expect(clipped[0].coordinates[0][0]).toBeCloseTo(0.002);
49+
});
50+
51+
it('advances immediately but ignores small backwards GPS noise', () => {
52+
const planned = route([leg('bike', [[0, 0], [0.01, 0]])]);
53+
let state = projectPositionOntoRoute(planned, { lng: 0.005, lat: 0 }, emptyRouteClippingState());
54+
state = projectPositionOntoRoute(planned, { lng: 0.006, lat: 0 }, state);
55+
const forward = state.accepted!.distanceAlongRoute;
56+
state = projectPositionOntoRoute(planned, { lng: 0.00598, lat: 0 }, state);
57+
expect(state.accepted!.distanceAlongRoute).toBe(forward);
58+
});
59+
60+
it('restores route geometry after meaningful backwards travel', () => {
61+
const planned = route([leg('bike', [[0, 0], [0.01, 0]])]);
62+
let state = projectPositionOntoRoute(planned, { lng: 0.006, lat: 0 }, emptyRouteClippingState());
63+
const before = state.accepted!.distanceAlongRoute;
64+
state = projectPositionOntoRoute(planned, { lng: 0.0058, lat: 0 }, state);
65+
expect(state.accepted!.distanceAlongRoute).toBeLessThan(before - 5);
66+
expect(clipRouteAtProjection(planned, state.accepted)[0].coordinates[0][0]).toBeCloseTo(0.0058);
67+
});
68+
69+
it('retains stable progress while off route and leaves an initially off-route route complete', () => {
70+
const planned = route([leg('foot', [[0, 0], [0.01, 0]])]);
71+
const initial = projectPositionOntoRoute(planned, { lng: 0.005, lat: 0.001 }, emptyRouteClippingState());
72+
expect(initial.accepted).toBeNull();
73+
expect(clipRouteAtProjection(planned, initial.accepted)[0].coordinates).toEqual(planned.legs[0].coordinates);
74+
const matched = projectPositionOntoRoute(planned, { lng: 0.005, lat: 0 }, initial);
75+
const offRoute = projectPositionOntoRoute(planned, { lng: 0.009, lat: 0.001 }, matched);
76+
expect(offRoute.accepted).toEqual(matched.accepted);
77+
});
78+
79+
it('prefers nearby route continuity at a crossing', () => {
80+
const coordinates: [number, number][] = [];
81+
for (let i = 0; i <= 25; i++) coordinates.push([i * 0.0001, 0]);
82+
for (let i = 25; i >= 0; i--) coordinates.push([i * 0.0001, 0.00002]);
83+
const planned = route([leg('bike', coordinates)]);
84+
let state = projectPositionOntoRoute(planned, { lng: 0.0002, lat: 0 }, emptyRouteClippingState());
85+
state = projectPositionOntoRoute(planned, { lng: 0.0002, lat: 0.00002 }, state);
86+
expect(state.accepted!.segmentOrder).toBeLessThanOrEqual(20);
87+
});
88+
89+
it('requires consecutive readings before a materially better distant rematch', () => {
90+
const coordinates: [number, number][] = [];
91+
for (let i = 0; i <= 25; i++) coordinates.push([i * 0.0001, 0]);
92+
for (let i = 25; i >= 0; i--) coordinates.push([i * 0.0001, 0.0003]);
93+
const planned = route([leg('bike', coordinates)]);
94+
let state = projectPositionOntoRoute(planned, { lng: 0.0002, lat: 0 }, emptyRouteClippingState());
95+
for (let i = 1; i < ROUTE_GLOBAL_REMATCH_READINGS; i++) {
96+
state = projectPositionOntoRoute(planned, { lng: 0.0002, lat: 0.0003 }, state);
97+
expect(state.accepted!.segmentOrder).toBeLessThanOrEqual(20);
98+
}
99+
state = projectPositionOntoRoute(planned, { lng: 0.0002, lat: 0.0003 }, state);
100+
expect(state.accepted!.segmentOrder).toBeGreaterThan(20);
101+
});
102+
103+
it('ignores degenerate geometry and never returns invalid lines', () => {
104+
const planned = route([
105+
leg('foot', []),
106+
leg('bike', [[0, 0]]),
107+
leg('foot', [[0, 0], [0, 0]]),
108+
leg('bike', [[0, 0], [0.001, 0]]),
109+
]);
110+
const state = projectPositionOntoRoute(planned, { lng: 0.0005, lat: 0 }, emptyRouteClippingState());
111+
const clipped = clipRouteAtProjection(planned, state.accepted);
112+
expect(clipped.every(l => l.coordinates.length >= 2)).toBe(true);
113+
expect(clipped).toHaveLength(1);
114+
});
115+
});

src/lib/route-clipping.ts

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import distance from '@turf/distance';
2+
import { lineString, point } from '@turf/helpers';
3+
import nearestPointOnLine from '@turf/nearest-point-on-line';
4+
import type { Coord, PlannedRoute, RouteLeg } from '$lib/routing';
5+
6+
export const ROUTE_BACKWARD_DEADBAND_METERS = 5;
7+
export const ROUTE_LOCAL_SEARCH_SEGMENTS = 20;
8+
export const ROUTE_OFF_ROUTE_METERS = 40;
9+
export const ROUTE_GLOBAL_REMATCH_READINGS = 3;
10+
export const ROUTE_GLOBAL_REMATCH_ADVANTAGE_METERS = 15;
11+
12+
export type RouteProjection = {
13+
legIndex: number,
14+
segmentIndex: number,
15+
segmentOrder: number,
16+
distanceAlongRoute: number,
17+
coordinate: [number, number],
18+
distanceFromRoute: number,
19+
};
20+
21+
export type RouteClippingState = {
22+
accepted: RouteProjection|null,
23+
pendingSegmentOrder: number|null,
24+
pendingReadings: number,
25+
};
26+
27+
export type DisplayRouteLeg = Pick<RouteLeg, 'mode'|'coordinates'>;
28+
29+
export const emptyRouteClippingState = (): RouteClippingState => ({
30+
accepted: null,
31+
pendingSegmentOrder: null,
32+
pendingReadings: 0,
33+
});
34+
35+
type Segment = {
36+
legIndex: number,
37+
segmentIndex: number,
38+
segmentOrder: number,
39+
coordinates: [[number, number], [number, number]],
40+
startDistance: number,
41+
};
42+
43+
function routeSegments(route: PlannedRoute): Segment[] {
44+
const segments: Segment[] = [];
45+
let startDistance = 0;
46+
for (let legIndex = 0; legIndex < route.legs.length; legIndex++) {
47+
const coordinates = route.legs[legIndex].coordinates;
48+
for (let segmentIndex = 0; segmentIndex + 1 < coordinates.length; segmentIndex++) {
49+
const pair: Segment['coordinates'] = [coordinates[segmentIndex], coordinates[segmentIndex + 1]];
50+
const length = distance(pair[0], pair[1], { units: 'meters' });
51+
if (length === 0) continue;
52+
segments.push({ legIndex, segmentIndex, segmentOrder: segments.length, coordinates: pair, startDistance });
53+
startDistance += length;
54+
}
55+
}
56+
return segments;
57+
}
58+
59+
function closestProjection(position: Coord, segments: Segment[], minOrder = 0, maxOrder = Infinity): RouteProjection|null {
60+
let best: RouteProjection|null = null;
61+
const location = point([position.lng, position.lat]);
62+
for (const segment of segments) {
63+
if (segment.segmentOrder < minOrder || segment.segmentOrder > maxOrder) continue;
64+
const snapped = nearestPointOnLine(lineString(segment.coordinates), location, { units: 'meters' });
65+
const candidate: RouteProjection = {
66+
legIndex: segment.legIndex,
67+
segmentIndex: segment.segmentIndex,
68+
segmentOrder: segment.segmentOrder,
69+
distanceAlongRoute: segment.startDistance + (snapped.properties.location ?? 0),
70+
coordinate: snapped.geometry.coordinates as [number, number],
71+
distanceFromRoute: snapped.properties.dist ?? Infinity,
72+
};
73+
if (!best || candidate.distanceFromRoute < best.distanceFromRoute) best = candidate;
74+
}
75+
return best;
76+
}
77+
78+
/** Match a GPS position to a route while retaining enough state to avoid noisy jumps. */
79+
export function projectPositionOntoRoute(route: PlannedRoute, position: Coord, state: RouteClippingState): RouteClippingState {
80+
const segments = routeSegments(route);
81+
const global = closestProjection(position, segments);
82+
if (!global || global.distanceFromRoute > ROUTE_OFF_ROUTE_METERS) {
83+
return { ...state, pendingSegmentOrder: null, pendingReadings: 0 };
84+
}
85+
86+
let candidate = global;
87+
let pendingSegmentOrder: number|null = null;
88+
let pendingReadings = 0;
89+
if (state.accepted) {
90+
const local = closestProjection(position, segments, state.accepted.segmentOrder - ROUTE_LOCAL_SEARCH_SEGMENTS, state.accepted.segmentOrder + ROUTE_LOCAL_SEARCH_SEGMENTS);
91+
if (local && Math.abs(global.segmentOrder - state.accepted.segmentOrder) > ROUTE_LOCAL_SEARCH_SEGMENTS) {
92+
if (global.distanceFromRoute + ROUTE_GLOBAL_REMATCH_ADVANTAGE_METERS < local.distanceFromRoute) {
93+
pendingSegmentOrder = global.segmentOrder;
94+
pendingReadings = state.pendingSegmentOrder === global.segmentOrder ? state.pendingReadings + 1 : 1;
95+
candidate = pendingReadings >= ROUTE_GLOBAL_REMATCH_READINGS ? global : local;
96+
if (candidate === global) {
97+
pendingSegmentOrder = null;
98+
pendingReadings = 0;
99+
}
100+
} else candidate = local;
101+
} else if (local) candidate = local;
102+
103+
if (candidate.distanceFromRoute > ROUTE_OFF_ROUTE_METERS) {
104+
return { ...state, pendingSegmentOrder, pendingReadings };
105+
}
106+
const backwards = state.accepted.distanceAlongRoute - candidate.distanceAlongRoute;
107+
if (backwards > 0 && backwards <= ROUTE_BACKWARD_DEADBAND_METERS) {
108+
return { accepted: state.accepted, pendingSegmentOrder, pendingReadings };
109+
}
110+
}
111+
return { accepted: candidate, pendingSegmentOrder, pendingReadings };
112+
}
113+
114+
/** Return display-only legs starting precisely at the accepted route projection. */
115+
export function clipRouteAtProjection(route: PlannedRoute, projection: RouteProjection|null): DisplayRouteLeg[] {
116+
if (!projection) return route.legs
117+
.filter(leg => leg.coordinates.length >= 2)
118+
.map(leg => ({ mode: leg.mode, coordinates: leg.coordinates }));
119+
120+
return route.legs.slice(projection.legIndex).flatMap((leg, relativeLegIndex) => {
121+
const coordinates = relativeLegIndex === 0 ?
122+
[projection.coordinate, ...leg.coordinates.slice(projection.segmentIndex + 1)] : leg.coordinates;
123+
const unique = coordinates.filter((coordinate, index) => index === 0 ||
124+
coordinate[0] !== coordinates[index - 1][0] || coordinate[1] !== coordinates[index - 1][1]);
125+
return unique.length >= 2 ? [{ mode: leg.mode, coordinates: unique }] : [];
126+
});
127+
}

0 commit comments

Comments
 (0)