Skip to content

Commit c298c6e

Browse files
authored
Merge pull request #1076 from OneBusAway/fix/shape-endpoint-spec-gaps
fix: shape endpoint polyline parity with Java (floor encoding, no point reduction)
2 parents 70aed4f + ad7a1cd commit c298c6e

4 files changed

Lines changed: 114 additions & 12 deletions

File tree

internal/restapi/shapes_handler.go

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ package restapi
33
import (
44
"net/http"
55

6-
"github.com/twpayne/go-polyline"
76
"maglev.onebusaway.org/internal/models"
7+
"maglev.onebusaway.org/internal/utils"
88
)
99

1010
// shapesHandler returns the encoded polyline shape for a route's geographic path.
@@ -34,18 +34,16 @@ func (api *RestAPI) shapesHandler(w http.ResponseWriter, r *http.Request) {
3434
return
3535
}
3636

37+
// Include every point with no simplification or consecutive-duplicate
38+
// filtering, matching the Java reference (ShapeBeanServiceImpl.getPolylineForShapeId).
3739
lineCoords := make([][]float64, 0, len(shapes))
38-
39-
for i, point := range shapes {
40-
// Filter consecutive duplicate points to avoid zero-length segments
41-
if i > 0 && point.Lat == shapes[i-1].Lat && point.Lon == shapes[i-1].Lon {
42-
continue
43-
}
40+
for _, point := range shapes {
4441
lineCoords = append(lineCoords, []float64{point.Lat, point.Lon})
4542
}
4643

47-
// Encode as a single continuous polyline to ensure valid delta offsets
48-
encodedPoints := string(polyline.EncodeCoords(lineCoords))
44+
// Encode using a floor-based encoder to stay byte-for-byte identical to the
45+
// Java PolylineEncoder (which floors coordinates rather than rounding).
46+
encodedPoints := utils.EncodePolyline(lineCoords)
4947

5048
shapeEntry := models.ShapeEntry{
5149
Length: len(lineCoords),

internal/restapi/shapes_handler_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,15 +187,15 @@ func TestShapesHandler_PolylineDecoding(t *testing.T) {
187187
expected: [][2]float64{{0.0, 0.0}, {1.0, 1.0}, {2.0, 2.0}, {1.0, 1.0}, {0.0, 0.0}},
188188
},
189189
{
190-
name: "Consecutive duplicates (B repeats) are deduplicated",
190+
name: "Consecutive duplicates are preserved (no point reduction)",
191191
shapeID: "duplicate_shape",
192192
input: []shapePoint{
193193
{0.0, 0.0, 1},
194194
{1.0, 1.0, 2},
195-
{1.0, 1.0, 3}, // duplicate of previous point — handler filters this
195+
{1.0, 1.0, 3}, // duplicate of previous point — must NOT be filtered (spec: all points included)
196196
{2.0, 2.0, 4},
197197
},
198-
expected: [][2]float64{{0.0, 0.0}, {1.0, 1.0}, {2.0, 2.0}},
198+
expected: [][2]float64{{0.0, 0.0}, {1.0, 1.0}, {1.0, 1.0}, {2.0, 2.0}},
199199
},
200200
}
201201

internal/utils/polyline.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package utils
2+
3+
import "math"
4+
5+
// EncodePolyline encodes an ordered sequence of [lat, lon] coordinate pairs into a
6+
// Google Encoded Polyline string.
7+
//
8+
// It deliberately mirrors the OneBusAway Java reference implementation
9+
// (org.onebusaway.geospatial.services.PolylineEncoder), which floors coordinates with
10+
// floor(coord * 1e5) rather than rounding. Matching the floor behaviour keeps maglev's
11+
// `points` output byte-for-byte identical to the Java server. All points are included;
12+
// no simplification or consecutive-duplicate filtering is performed.
13+
func EncodePolyline(coords [][]float64) string {
14+
var b []byte
15+
var plat, plng int
16+
for _, c := range coords {
17+
late5 := floor1e5(c[0])
18+
lnge5 := floor1e5(c[1])
19+
b = encodeSignedNumber(b, late5-plat)
20+
b = encodeSignedNumber(b, lnge5-plng)
21+
plat = late5
22+
plng = lnge5
23+
}
24+
return string(b)
25+
}
26+
27+
func floor1e5(coordinate float64) int {
28+
return int(math.Floor(coordinate * 1e5))
29+
}
30+
31+
func encodeSignedNumber(b []byte, num int) []byte {
32+
sgnNum := num << 1
33+
if num < 0 {
34+
sgnNum = ^sgnNum
35+
}
36+
return encodeNumber(b, sgnNum)
37+
}
38+
39+
func encodeNumber(b []byte, num int) []byte {
40+
for num >= 0x20 {
41+
b = append(b, byte((0x20|(num&0x1f))+63))
42+
num >>= 5
43+
}
44+
return append(b, byte(num+63))
45+
}

internal/utils/polyline_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package utils
2+
3+
import "testing"
4+
5+
func TestEncodePolyline_GoogleExample(t *testing.T) {
6+
// Canonical example from Google's polyline algorithm documentation.
7+
coords := [][]float64{
8+
{38.5, -120.2},
9+
{40.7, -120.95},
10+
{43.252, -126.453},
11+
}
12+
got := EncodePolyline(coords)
13+
want := "_p~iF~ps|U_ulLnnqC_mqNvxq`@"
14+
if got != want {
15+
t.Errorf("EncodePolyline() = %q, want %q", got, want)
16+
}
17+
}
18+
19+
func TestEncodePolyline_FloorsNotRounds(t *testing.T) {
20+
// 0.000019 * 1e5 = 1.9. Java floors to 1 (same as 0.00001); a rounding
21+
// encoder would yield 2 (same as 0.00002). Verify we floor.
22+
got := EncodePolyline([][]float64{{0.000019, 0}})
23+
floored := EncodePolyline([][]float64{{0.00001, 0}})
24+
rounded := EncodePolyline([][]float64{{0.00002, 0}})
25+
if got != floored {
26+
t.Errorf("floor(0.000019*1e5) should match 0.00001 encoding; got %q want %q", got, floored)
27+
}
28+
if got == rounded {
29+
t.Errorf("floored encoding should differ from rounded 0.00002 encoding %q", rounded)
30+
}
31+
32+
// Negative boundary: -0.000019 * 1e5 = -1.9. Java floors to -2 (same as
33+
// -0.00002); truncation or rounding would give -1 (same as -0.00001).
34+
gotNeg := EncodePolyline([][]float64{{-0.000019, 0}})
35+
flooredNeg := EncodePolyline([][]float64{{-0.00002, 0}})
36+
truncOrRoundNeg := EncodePolyline([][]float64{{-0.00001, 0}})
37+
if gotNeg != flooredNeg {
38+
t.Errorf("floor(-0.000019*1e5) should match -0.00002 encoding; got %q want %q", gotNeg, flooredNeg)
39+
}
40+
if gotNeg == truncOrRoundNeg {
41+
t.Errorf("floored negative encoding should differ from -0.00001 (truncate/round) encoding %q", truncOrRoundNeg)
42+
}
43+
}
44+
45+
func TestEncodePolyline_PreservesDuplicates(t *testing.T) {
46+
// A consecutive duplicate point must still be encoded (delta 0,0), not dropped.
47+
coords := [][]float64{{1.0, 1.0}, {1.0, 1.0}, {2.0, 2.0}}
48+
withDup := EncodePolyline(coords)
49+
withoutDup := EncodePolyline([][]float64{{1.0, 1.0}, {2.0, 2.0}})
50+
if withDup == withoutDup {
51+
t.Errorf("duplicate point should add a zero-delta segment; encodings should differ")
52+
}
53+
}
54+
55+
func TestEncodePolyline_Empty(t *testing.T) {
56+
if got := EncodePolyline(nil); got != "" {
57+
t.Errorf("EncodePolyline(nil) = %q, want empty string", got)
58+
}
59+
}

0 commit comments

Comments
 (0)