polyline6.go
1// Package geo provides polyline6 encoding/decoding utilities for working with
2// geographic coordinates. It wraps the github.com/twpayne/go-polyline library
3// with a custom codec using dimension 2 and scale 1e6 (polyline6 format).
4//
5// All coordinate operations use Maplibre format [lon, lat] and convert
6// appropriately when calling the underlying library functions which use
7// [lat, lon] format.
8//
9// The Polyline6 type handles JSON escaping transparently - when unmarshaling
10// from JSON, escaped backslashes (\\) are unescaped to (\), and when
11// marshaling to JSON, backslashes are escaped.
12//
13// Example usage:
14//
15// points := [][]float64{{-120.2, 38.5}, {-120.95, 40.7}}
16// encoded, err := geo.Encode(points)
17// decoded, err := geo.Decode(encoded)
18// combined, err := geo.Combine(encoded1, encoded2)
19// geojson, err := geo.ToGeoJSON(encoded, geo.WithProperty("name", "Route"))
20package geo
21
22import (
23 "encoding/json"
24 "fmt"
25 "log/slog"
26 "strings"
27
28 "github.com/twpayne/go-polyline"
29)
30
31// codec is the polyline6 codec: 2 dimensions (lat, lon), 1e6 scale factor
32var codec = polyline.Codec{Dim: 2, Scale: 1e6}
33
34// Encode encodes Maplibre format points [lon, lat] to a polyline6 string.
35// The polyline format uses [lat, lon] internally, so this function swaps
36// the coordinates before encoding.
37func Encode(points [][]float64) (Polyline6, error) {
38 if len(points) == 0 {
39 return "", nil
40 }
41
42 // Convert from Maplibre [lon, lat] to polyline [lat, lon] format
43 coords := make([][]float64, len(points))
44 for i, pt := range points {
45 if len(pt) != 2 {
46 return "", fmt.Errorf("point %d: expected 2 coordinates, got %d", i, len(pt))
47 }
48 // Swap: [lon, lat] -> [lat, lon]
49 coords[i] = []float64{pt[1], pt[0]}
50 }
51
52 encoded := codec.EncodeCoords(nil, coords)
53 return Polyline6(encoded), nil
54}
55
56// Decode decodes a polyline6 string to Maplibre format points [lon, lat].
57// The polyline format uses [lat, lon] internally, so this function swaps
58// the coordinates after decoding.
59//
60// This function is defensive against malformed polylines that may have trailing
61// garbage bytes. If the initial decode fails, it will try progressively trimming
62// trailing bytes (up to 5) to find a valid polyline.
63func Decode(p Polyline6) ([][]float64, error) {
64 if p == "" {
65 return [][]float64{}, nil
66 }
67
68 coords, _, err := codec.DecodeCoords([]byte(p))
69 if err != nil {
70 // Defensive: try trimming trailing bytes that might be garbage
71 // This handles cases where the routing API returns extra bytes
72 // or there's a duplicate terminator at the end
73 for trim := 1; trim <= 5 && trim < len(p); trim++ {
74 trimmed := p[:len(p)-trim]
75 retryCoords, _, retryErr := codec.DecodeCoords([]byte(trimmed))
76 if retryErr == nil && len(retryCoords) > 0 {
77 // Successfully decoded after trimming
78 slog.Debug("polyline decoded successfully after trimming trailing bytes",
79 "trimmedBytes", trim,
80 "originalLength", len(p),
81 "decodedCoords", len(retryCoords))
82 return convertCoords(retryCoords)
83 }
84 }
85 return nil, fmt.Errorf("decode error: %w (polyline length=%d, first20=%q)", err, len(p), string([]byte(p)[:min(20, len(p))]))
86 }
87
88 return convertCoords(coords)
89}
90
91// DecodeManual decodes a polyline6 string using a clean-room implementation
92// of the Google polyline algorithm with 1e6 precision.
93// Returns points in Maplibre format [lon, lat].
94func DecodeManual(p Polyline6) ([][]float64, error) {
95 if p == "" {
96 return [][]float64{}, nil
97 }
98
99 var points [][]float64
100 var lat, lng int64 // Accumulated values in 1e6 precision
101
102 for i := 0; i < len(p); {
103 // Decode latitude delta
104 latDelta, bytesRead := decodeSignedNumber(string(p), i)
105 if bytesRead == 0 {
106 return nil, fmt.Errorf("failed to decode latitude at position %d", i)
107 }
108 i += bytesRead
109 lat += latDelta
110
111 // Decode longitude delta
112 if i >= len(p) {
113 return nil, fmt.Errorf("incomplete polyline: missing longitude after latitude at position %d", i)
114 }
115 lngDelta, bytesRead := decodeSignedNumber(string(p), i)
116 if bytesRead == 0 {
117 return nil, fmt.Errorf("failed to decode longitude at position %d", i)
118 }
119 i += bytesRead
120 lng += lngDelta
121
122 // Convert from 1e6 fixed point to float64 and swap to [lon, lat]
123 points = append(points, []float64{
124 float64(lng) / 1e6,
125 float64(lat) / 1e6,
126 })
127 }
128
129 return points, nil
130}
131
132// decodeSignedNumber decodes a single signed variable-length number
133// from the polyline starting at position pos. Returns the decoded value
134// and the number of bytes consumed.
135func decodeSignedNumber(s string, pos int) (int64, int) {
136 var result int64
137 var shift uint
138
139 for {
140 if pos >= len(s) {
141 return 0, 0 // Error: incomplete number
142 }
143
144 // Get next character and decode
145 b := int64(s[pos] - 63)
146 pos++
147
148 // Extract 5 bits and add to result
149 result |= (b & 0x1F) << shift
150 shift += 5
151
152 // Check continuation bit
153 if (b & 0x20) == 0 {
154 break
155 }
156
157 // Sanity check: prevent infinite loop on malformed data
158 if shift > 64 {
159 return 0, 0
160 }
161 }
162
163 // Handle sign: if result is odd, negate it after shifting
164 if result&1 == 1 {
165 result = ^(result >> 1)
166 } else {
167 result = result >> 1
168 }
169
170 return result, int(shift / 5)
171}
172
173// convertCoords converts polyline [lat, lon] coordinates to Maplibre [lon, lat] format
174func convertCoords(coords [][]float64) ([][]float64, error) {
175 points := make([][]float64, len(coords))
176 for i, coord := range coords {
177 if len(coord) != 2 {
178 return nil, fmt.Errorf("decoded point %d: expected 2 coordinates, got %d", i, len(coord))
179 }
180 // Swap: [lat, lon] -> [lon, lat]
181 points[i] = []float64{coord[1], coord[0]}
182 }
183 return points, nil
184}
185
186// Polyline6 is an unescaped polyline6 string.
187// It implements json.Marshaler and json.Unmarshaler to handle JSON escaping
188// transparently - JSON responses with escaped backslashes are unescaped when
189// unmarshaling, and backslashes are escaped when marshaling to JSON.
190type Polyline6 string
191
192// UnmarshalJSON implements json.Unmarshaler.
193// It removes JSON escaping from backslashes (\\\\ becomes \\).
194func (p *Polyline6) UnmarshalJSON(data []byte) error {
195 // Remove surrounding quotes
196 var s string
197 if err := json.Unmarshal(data, &s); err != nil {
198 return err
199 }
200 // Unescape: replace \\ with \ (undo JSON escaping)
201 *p = Polyline6(strings.ReplaceAll(s, `\\`, `\`))
202 return nil
203}
204
205// MarshalJSON implements json.Marshaler.
206// It adds JSON escaping to backslashes (\ becomes \\).
207func (p Polyline6) MarshalJSON() ([]byte, error) {
208 // Escape: replace \ with \\
209 escaped := strings.ReplaceAll(string(p), `\`, `\\`)
210 return json.Marshal(escaped)
211}
212
213// Combine concatenates two polylines, removing any repeated points at the junction.
214// It decodes both polylines, removes duplicate points where they meet, and re-encodes.
215// Returns an unescaped polyline6.
216func Combine(first, second Polyline6) (Polyline6, error) {
217 // Handle empty cases
218 if first == "" {
219 return second, nil
220 }
221 if second == "" {
222 return first, nil
223 }
224
225 // Decode both polylines
226 firstPoints, err := Decode(first)
227 if err != nil {
228 return "", fmt.Errorf("decode first polyline: %w", err)
229 }
230
231 secondPoints, err := Decode(second)
232 if err != nil {
233 return "", fmt.Errorf("decode second polyline: %w", err)
234 }
235
236 // Find and remove overlapping points from the end of first and start of second
237 // Compare from the end of first with start of second
238 overlap := 0
239 maxOverlap := min(len(firstPoints), len(secondPoints))
240
241 for i := 1; i <= maxOverlap; i++ {
242 // Check if last i points of first match first i points of second
243 matches := true
244 for j := 0; j < i; j++ {
245 firstIdx := len(firstPoints) - i + j
246 if !pointsEqual(firstPoints[firstIdx], secondPoints[j]) {
247 matches = false
248 break
249 }
250 }
251 if matches {
252 overlap = i
253 }
254 }
255
256 // Combine: all of first + second without the overlapping prefix
257 var combined [][]float64
258 combined = append(combined, firstPoints...)
259 if overlap < len(secondPoints) {
260 combined = append(combined, secondPoints[overlap:]...)
261 }
262
263 return Encode(combined)
264}
265
266// pointsEqual checks if two points are equal within floating point tolerance
267func pointsEqual(a, b []float64) bool {
268 if len(a) != 2 || len(b) != 2 {
269 return false
270 }
271 const epsilon = 0.00001
272 return abs(a[0]-b[0]) < epsilon && abs(a[1]-b[1]) < epsilon
273}
274
275func min(a, b int) int {
276 if a < b {
277 return a
278 }
279 return b
280}
281
282// abs returns the absolute value of a float64
283func abs(f float64) float64 {
284 if f < 0 {
285 return -f
286 }
287 return f
288}
289
290// GeoJSONFeature represents a GeoJSON Feature object with LineString geometry
291type GeoJSONFeature struct {
292 Type string `json:"type"`
293 Geometry GeoJSONLineString `json:"geometry"`
294 Properties map[string]interface{} `json:"properties"`
295}
296
297// GeoJSONLineString represents a GeoJSON LineString geometry
298type GeoJSONLineString struct {
299 Type string `json:"type"`
300 Coordinates [][]float64 `json:"coordinates"`
301}
302
303// GeoJSONOption is a functional option for configuring GeoJSON output
304type GeoJSONOption func(*GeoJSONFeature)
305
306// WithProperty adds a custom property to the GeoJSON Feature
307func WithProperty(key string, value interface{}) GeoJSONOption {
308 return func(f *GeoJSONFeature) {
309 f.Properties[key] = value
310 }
311}
312
313// ToGeoJSON converts a polyline6 to a GeoJSON Feature with LineString geometry.
314// Coordinates are in Maplibre format [lon, lat].
315// Use functional options to add custom properties.
316func ToGeoJSON(p Polyline6, opts ...GeoJSONOption) (*GeoJSONFeature, error) {
317 points, err := Decode(p)
318 if err != nil {
319 return nil, fmt.Errorf("decode polyline: %w", err)
320 }
321
322 feature := &GeoJSONFeature{
323 Type: "Feature",
324 Geometry: GeoJSONLineString{
325 Type: "LineString",
326 Coordinates: points,
327 },
328 Properties: make(map[string]interface{}),
329 }
330
331 // Apply functional options
332 for _, opt := range opts {
333 opt(feature)
334 }
335
336 return feature, nil
337}