Skip to content

Commit bc9f118

Browse files
committed
fix: route collapses to a handful of points when the endpoint closes a loop
The endpoint mid-edge snap logic truncated the route at "the last time the trail traversed the snap edge" — but the Eulerian circuit's traversal order isn't dictated by click order. With a long/complex route, a street shared with an earlier segment (e.g. clicking an endpoint near the route's own start, a normal way to close a loop) could appear only once, far from the actual end. That single early match got truncated as if it were the terminus, discarding the rest of the route (reported case: 1007 points collapsed to 4). Now only trusts a match within ~10% of the trail's tail as the real terminus; otherwise leaves the route untouched rather than risk destroying most of it. Extracted the endpoint-snap logic into a standalone applyEndpointSnap() so it's directly testable without depending on the CPP solver's heuristic ordering.
1 parent 992275d commit bc9f118

4 files changed

Lines changed: 128 additions & 40 deletions

File tree

‎lib/graph.test.ts‎

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { StreetGraph, pointInPolygon, pointInAnyPolygon, pointNearOrInPolygon, getPolygonBounds, trimBridgeOverlap } from './graph';
1+
import { StreetGraph, pointInPolygon, pointInAnyPolygon, pointNearOrInPolygon, getPolygonBounds, trimBridgeOverlap, applyEndpointSnap } from './graph';
22
import { OverpassResponse } from './types';
33

44
describe('StreetGraph', () => {
@@ -290,6 +290,71 @@ describe('StreetGraph', () => {
290290
expect(last.lon).not.toBeCloseTo(0.002, 9);
291291
});
292292

293+
describe('applyEndpointSnap', () => {
294+
// A straight chain, node ids as letters, ~111m apart (0.001 deg).
295+
// A-B is the "shared" edge: it appears once near the start (index 0-1)
296+
// and the trail continues on for many more points afterward.
297+
const A = { lat: 0, lon: 0 };
298+
const B = { lat: 0, lon: 0.001 };
299+
const uv = { u: 'A', v: 'B' };
300+
301+
function longTrailStartingWithAB(tailLength: number) {
302+
const coords: { lat: number; lon: number }[] = [A, B];
303+
for (let i = 1; i <= tailLength; i++) coords.push({ lat: 0, lon: 0.001 + i * 0.001 });
304+
return coords;
305+
}
306+
307+
test('regression: does not collapse a long route when the endpoint snaps onto an edge used only near the start', () => {
308+
// User's reported bug: clicked an endpoint close to the route's own
309+
// start (a normal way to close a loop) — the snap edge (A-B) was
310+
// only traversed once, right at the beginning, with 40 more points
311+
// after it. The old code truncated to 3 points; it must not now.
312+
const coords = longTrailStartingWithAB(40);
313+
const endPoint = { lat: 0, lon: 0.0004 }; // mid-edge on A-B, ~44m from A
314+
const snap = { lat: 0.0, lon: 0.0004, ...uv };
315+
316+
const result = applyEndpointSnap(coords, endPoint, snap, A, B);
317+
318+
expect(result.length).toBe(coords.length); // untouched — real end lies elsewhere
319+
});
320+
321+
test('still truncates when the snap edge is genuinely at the end of a short route', () => {
322+
const coords = [A, B]; // A-B is the entire (short) route
323+
const endPoint = { lat: 0, lon: 0.0004 };
324+
const snap = { lat: 0.0, lon: 0.0004, ...uv };
325+
326+
const result = applyEndpointSnap(coords, endPoint, snap, A, B);
327+
328+
const last = result[result.length - 1];
329+
expect(last.lat).toBeCloseTo(endPoint.lat, 9);
330+
expect(last.lon).toBeCloseTo(endPoint.lon, 9);
331+
});
332+
333+
test('still truncates when the snap edge is the last edge of a long route', () => {
334+
const tail = longTrailStartingWithAB(40);
335+
// Put A-B at the very end instead of the start.
336+
const coords = [...tail.slice(2), A, B];
337+
const endPoint = { lat: 0, lon: 0.0004 };
338+
const snap = { lat: 0.0, lon: 0.0004, ...uv };
339+
340+
const result = applyEndpointSnap(coords, endPoint, snap, A, B);
341+
342+
const last = result[result.length - 1];
343+
expect(last.lat).toBeCloseTo(endPoint.lat, 9);
344+
expect(last.lon).toBeCloseTo(endPoint.lon, 9);
345+
});
346+
347+
test('leaves coords untouched when the click is effectively at an intersection, not mid-edge', () => {
348+
const coords = longTrailStartingWithAB(10);
349+
const endPoint = { lat: 0, lon: 0.0000001 }; // right on node A
350+
const snap = { lat: 0, lon: 0.0000001, ...uv };
351+
352+
const result = applyEndpointSnap(coords, endPoint, snap, A, B);
353+
354+
expect(result).toBe(coords);
355+
});
356+
});
357+
293358
test('mixed mode (manualRoute + selectionBox) produces open path ending at far corner, not back at entry', () => {
294359
// 3x3 grid — approach from outside the NW corner, selection box covers all 9 nodes.
295360
// NW=1(0.002,0) - 2(0.002,0.001) - NE=3(0.002,0.002)

‎lib/graph.ts‎

Lines changed: 59 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import path from 'ngraph.path';
55

66
import { OSMWay, OSMNode, OverpassResponse } from './types';
77
import { isRoutableHighway } from './highwayFilter';
8+
import { haversineM } from './geometry';
89

910
interface NodeData {
1011
lat: number;
@@ -131,6 +132,63 @@ export function pointNearOrInAnyPolygon(point: [number, number], polygons: [numb
131132
return polygons.some(polygon => pointNearOrInPolygon(point, polygon, bufferMeters));
132133
}
133134

135+
// Projects a mid-edge endpoint click onto its closest edge and trims/extends
136+
// the trail's coords so the route ends exactly at that point.
137+
//
138+
// The Eulerian circuit's traversal order isn't dictated by click order — with
139+
// a long/complex route, a street shared with an earlier segment (e.g. the
140+
// endpoint clicked near the route's own start, a normal way to close a loop)
141+
// can appear only once, and much earlier than the real end. Truncating there
142+
// would silently discard the rest of the route down to just a handful of
143+
// points, so a match is only trusted as the true terminus when it's actually
144+
// near the tail.
145+
export function applyEndpointSnap(
146+
coords: { lat: number; lon: number; hasConstruction?: boolean }[],
147+
endPoint: { lat: number; lon: number },
148+
snap: { lat: number; lon: number; u: string; v: string },
149+
uCoord: { lat: number; lon: number },
150+
vCoord: { lat: number; lon: number },
151+
): { lat: number; lon: number; hasConstruction?: boolean }[] {
152+
const distToSnap = haversineM(endPoint.lat, endPoint.lon, snap.lat, snap.lon);
153+
const distToU = haversineM(endPoint.lat, endPoint.lon, uCoord.lat, uCoord.lon);
154+
const distToV = haversineM(endPoint.lat, endPoint.lon, vCoord.lat, vCoord.lon);
155+
const minNodeDist = Math.min(distToU, distToV);
156+
157+
// Only truncate when the click is genuinely mid-edge, not just slightly
158+
// off an intersection due to map rounding. ~5m gap between node and edge snap.
159+
const NODE_THRESHOLD_M = 5;
160+
if (minNodeDist - distToSnap <= NODE_THRESHOLD_M) return coords;
161+
162+
const eq = (a: number, b: number) => Math.abs(a - b) < 1e-9;
163+
const isU = (c: { lat: number, lon: number }) => eq(c.lat, uCoord.lat) && eq(c.lon, uCoord.lon);
164+
const isV = (c: { lat: number, lon: number }) => eq(c.lat, vCoord.lat) && eq(c.lon, vCoord.lon);
165+
166+
// Find the last time the route traversed the snap edge.
167+
let lastIdx = -1;
168+
for (let i = 0; i < coords.length - 1; i++) {
169+
const a = coords[i], b = coords[i + 1];
170+
if ((isU(a) && isV(b)) || (isV(a) && isU(b))) lastIdx = i;
171+
}
172+
173+
const NEAR_END_FRACTION = 0.1;
174+
const allowedFromEnd = Math.max(5, Math.ceil(coords.length * NEAR_END_FRACTION));
175+
const isNearEnd = lastIdx !== -1 && (coords.length - 1 - lastIdx) <= allowedFromEnd;
176+
if (isNearEnd) {
177+
// Truncate at the snap point during that traversal.
178+
return [...coords.slice(0, lastIdx + 1), { lat: snap.lat, lon: snap.lon }];
179+
}
180+
181+
// The snap edge either wasn't traversed, or only appeared far from the
182+
// end. If the trail's final node is one of the snap edge's endpoints,
183+
// extend along that edge to the snap point; otherwise leave coords as-is
184+
// rather than risk discarding most of the route.
185+
const last = coords[coords.length - 1];
186+
if (isU(last) || isV(last)) {
187+
return [...coords, { lat: snap.lat, lon: snap.lon }];
188+
}
189+
return coords;
190+
}
191+
134192
// The entry bridge (approach → area sweep, mixed mode) carries no coverage
135193
// guarantee — only the area's own CPP-solved path does. So any edge the
136194
// bridge shares with the path's own leading edges is pure redundant mileage:
@@ -2084,42 +2142,7 @@ export class StreetGraph {
20842142
const uNode = this.graph.getNode(snap.u);
20852143
const vNode = this.graph.getNode(snap.v);
20862144
if (uNode && vNode) {
2087-
const distToSnap = this.haversine(endPoint.lat, endPoint.lon, snap.lat, snap.lon);
2088-
const distToU = this.haversine(endPoint.lat, endPoint.lon, uNode.data.lat, uNode.data.lon);
2089-
const distToV = this.haversine(endPoint.lat, endPoint.lon, vNode.data.lat, vNode.data.lon);
2090-
const minNodeDist = Math.min(distToU, distToV);
2091-
2092-
// Only truncate when the click is genuinely mid-edge,
2093-
// not just slightly off an intersection due to map
2094-
// rounding. ~5m gap between node and edge snap.
2095-
const NODE_THRESHOLD_M = 5;
2096-
if (minNodeDist - distToSnap > NODE_THRESHOLD_M) {
2097-
const eq = (a: number, b: number) => Math.abs(a - b) < 1e-9;
2098-
const isU = (c: { lat: number, lon: number }) =>
2099-
eq(c.lat, uNode.data.lat) && eq(c.lon, uNode.data.lon);
2100-
const isV = (c: { lat: number, lon: number }) =>
2101-
eq(c.lat, vNode.data.lat) && eq(c.lon, vNode.data.lon);
2102-
2103-
// Find the last time the route traversed the snap edge.
2104-
let lastIdx = -1;
2105-
for (let i = 0; i < coords.length - 1; i++) {
2106-
const a = coords[i], b = coords[i + 1];
2107-
if ((isU(a) && isV(b)) || (isV(a) && isU(b))) lastIdx = i;
2108-
}
2109-
2110-
if (lastIdx !== -1) {
2111-
// Truncate at the snap point during that traversal.
2112-
return [...coords.slice(0, lastIdx + 1), { lat: snap.lat, lon: snap.lon }];
2113-
}
2114-
2115-
// The snap edge was not traversed. If the trail's
2116-
// final node is one of the snap edge's endpoints,
2117-
// extend along that edge to the snap point.
2118-
const last = coords[coords.length - 1];
2119-
if (isU(last) || isV(last)) {
2120-
coords.push({ lat: snap.lat, lon: snap.lon });
2121-
}
2122-
}
2145+
return applyEndpointSnap(coords, endPoint, snap, uNode.data, vNode.data);
21232146
}
21242147
}
21252148
}

‎package-lock.json‎

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

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "street-sweep",
3-
"version": "0.6.50",
3+
"version": "0.6.51",
44
"private": true,
55
"scripts": {
66
"dev": "next dev -p 3888",

0 commit comments

Comments
 (0)