Skip to content

Commit 8964039

Browse files
committed
feat: implementation of polyline decoding for S2, GMP Encoded, and JSON formats
1 parent af5eb72 commit 8964039

File tree

4 files changed

+179
-45
lines changed

4 files changed

+179
-45
lines changed

src/Map.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,13 @@ function MapComponent({
275275
log("handlePolylineSubmit called.");
276276

277277
const path = waypoints.map((wp) => new window.google.maps.LatLng(wp.latitude, wp.longitude));
278-
const newPolyline = new window.google.maps.Polyline({ path, geodesic: true, ...properties });
278+
const newPolyline = new window.google.maps.Polyline({
279+
path,
280+
geodesic: true,
281+
strokeColor: properties.color,
282+
strokeOpacity: properties.opacity,
283+
strokeWeight: properties.strokeWeight,
284+
});
279285
newPolyline.setMap(map);
280286
setPolylines((prev) => [...prev, newPolyline]);
281287
}, []);

src/PolylineCreation.js

Lines changed: 4 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// src/PolylineCreation.js
22

33
import { useState } from "react";
4-
import { decode } from "s2polyline-ts";
54
import { log } from "./Utils";
5+
import { parsePolylineInput } from "./PolylineUtils";
66

77
function PolylineCreation({ onSubmit, onClose, buttonPosition }) {
88
const [input, setInput] = useState("");
@@ -13,60 +13,20 @@ function PolylineCreation({ onSubmit, onClose, buttonPosition }) {
1313
const handleSubmit = (e) => {
1414
e.preventDefault();
1515
try {
16-
const trimmedInput = input.trim();
17-
18-
// Check if input looks like an encoded polyline (single string without spaces)
19-
if (/^[A-Za-z0-9+/=\-_]+$/.test(trimmedInput)) {
20-
log("Attempting to decode S2 polyline:", trimmedInput);
21-
const decodedPoints = decode(trimmedInput);
22-
23-
if (decodedPoints && decodedPoints.length > 0) {
24-
// Convert S2 points to our expected format
25-
const validWaypoints = decodedPoints.map((point) => ({
26-
latitude: point.latDegrees(),
27-
longitude: point.lngDegrees(),
28-
}));
29-
30-
log(`Decoded ${validWaypoints.length} points from S2 polyline`);
31-
onSubmit(validWaypoints, { opacity, color, strokeWeight });
32-
setInput("");
33-
return;
34-
}
35-
}
36-
37-
// Existing JSON parsing logic
38-
const jsonString = trimmedInput.replace(/(\w+):/g, '"$1":').replace(/\s+/g, " ");
39-
40-
const inputWithBrackets = jsonString.startsWith("[") && jsonString.endsWith("]") ? jsonString : `[${jsonString}]`;
41-
42-
const waypoints = JSON.parse(inputWithBrackets);
43-
44-
const validWaypoints = waypoints.filter(
45-
(waypoint) =>
46-
typeof waypoint === "object" &&
47-
"latitude" in waypoint &&
48-
"longitude" in waypoint &&
49-
typeof waypoint.latitude === "number" &&
50-
typeof waypoint.longitude === "number"
51-
);
52-
53-
if (validWaypoints.length === 0) {
54-
throw new Error("No valid waypoints found");
55-
}
56-
16+
const validWaypoints = parsePolylineInput(input);
5717
log(`Parsed ${validWaypoints.length} valid waypoints`);
5818
onSubmit(validWaypoints, { opacity, color, strokeWeight });
19+
setInput("");
5920
} catch (error) {
6021
log("Invalid input format:", error);
6122
}
62-
setInput("");
6323
};
6424

6525
let placeholder = `Paste waypoints here:
6626
{ latitude: 52.5163, longitude: 13.2399 },
6727
{ latitude: 52.5162, longitude: 13.2400 }
6828
69-
Or paste an encoded S2 polyline string`;
29+
Or paste an encoded S2 or Google Maps polyline string`;
7030

7131
return (
7232
<div

src/PolylineUtils.js

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { decode as decodeS2 } from "s2polyline-ts";
2+
3+
/**
4+
* Decodes a Google Maps encoded polyline string into an array of LatLng objects.
5+
* Based on the Google Polyline Algorithm.
6+
* @param {string} encoded
7+
* @returns {Array<{latitude: number, longitude: number}>}
8+
*/
9+
export function decodeGooglePolyline(encoded) {
10+
const points = [];
11+
let index = 0,
12+
len = encoded.length;
13+
let lat = 0,
14+
lng = 0;
15+
16+
while (index < len) {
17+
let b,
18+
shift = 0,
19+
result = 0;
20+
do {
21+
b = encoded.charCodeAt(index++) - 63;
22+
result |= (b & 0x1f) << shift;
23+
shift += 5;
24+
} while (b >= 0x20);
25+
const dlat = result & 1 ? ~(result >> 1) : result >> 1;
26+
lat += dlat;
27+
28+
shift = 0;
29+
result = 0;
30+
do {
31+
b = encoded.charCodeAt(index++) - 63;
32+
result |= (b & 0x1f) << shift;
33+
shift += 5;
34+
} while (b >= 0x20);
35+
const dlng = result & 1 ? ~(result >> 1) : result >> 1;
36+
lng += dlng;
37+
38+
points.push({
39+
latitude: lat / 1e5,
40+
longitude: lng / 1e5,
41+
});
42+
}
43+
44+
return points;
45+
}
46+
47+
/**
48+
* Parses polyline input in various formats: S2, Google Encoded, JSON, or Plain Text.
49+
* @param {string} input
50+
* @returns {Array<{latitude: number, longitude: number}>}
51+
*/
52+
export function parsePolylineInput(input) {
53+
const trimmedInput = input.trim();
54+
55+
// Check if it's obviously JSON or plain text coordinate list
56+
const isJsonLike = (trimmedInput.startsWith("[") || trimmedInput.startsWith("{")) && trimmedInput.includes(":");
57+
58+
if (!isJsonLike) {
59+
try {
60+
// S2 strings usually don't have spaces or certain JSON characters
61+
if (!trimmedInput.includes(" ") && !trimmedInput.includes('"')) {
62+
const decodedPoints = decodeS2(trimmedInput);
63+
if (decodedPoints && decodedPoints.length > 0) {
64+
return decodedPoints.map((point) => ({
65+
latitude: point.latDegrees(),
66+
longitude: point.lngDegrees(),
67+
}));
68+
}
69+
}
70+
} catch (e) {
71+
// Continue to next format
72+
}
73+
74+
try {
75+
// Sanity check: Google polylines shouldn't have spaces or newlines
76+
if (!trimmedInput.includes("\n") && !trimmedInput.includes(" ")) {
77+
const decodedPoints = decodeGooglePolyline(trimmedInput);
78+
if (decodedPoints && decodedPoints.length > 0) {
79+
return decodedPoints;
80+
}
81+
}
82+
} catch (e) {
83+
// Continue to next format
84+
}
85+
}
86+
87+
try {
88+
const jsonString = trimmedInput.replace(/(\w+):/g, '"$1":').replace(/\s+/g, " ");
89+
const inputWithBrackets = jsonString.startsWith("[") && jsonString.endsWith("]") ? jsonString : `[${jsonString}]`;
90+
const waypoints = JSON.parse(inputWithBrackets);
91+
92+
const validWaypoints = waypoints.filter(
93+
(waypoint) =>
94+
typeof waypoint === "object" &&
95+
"latitude" in waypoint &&
96+
"longitude" in waypoint &&
97+
typeof waypoint.latitude === "number" &&
98+
typeof waypoint.longitude === "number"
99+
);
100+
101+
if (validWaypoints.length > 0) {
102+
return validWaypoints;
103+
}
104+
} catch (e) {
105+
// Fall through to error
106+
}
107+
108+
throw new Error("Invalid polyline format or no valid coordinates found.");
109+
}

src/PolylineUtils.test.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { parsePolylineInput } from "./PolylineUtils";
2+
3+
describe("PolylineUtils", () => {
4+
const EXPECTED_POINTS = [
5+
{ latitude: 37.42213, longitude: -122.0848 },
6+
{ latitude: 37.4152, longitude: -122.0627 },
7+
{ latitude: 37.427, longitude: -122.0854 },
8+
];
9+
10+
test("decodes a GMP Encoded Polyline", () => {
11+
const encoded = "i_lcF~tchVhj@ciCwhAzlC";
12+
const decoded = parsePolylineInput(encoded);
13+
14+
expect(decoded).toHaveLength(EXPECTED_POINTS.length);
15+
for (let i = 0; i < EXPECTED_POINTS.length; i++) {
16+
expect(decoded[i].latitude).toBeCloseTo(EXPECTED_POINTS[i].latitude, 5);
17+
expect(decoded[i].longitude).toBeCloseTo(EXPECTED_POINTS[i].longitude, 5);
18+
}
19+
});
20+
21+
test("decodes an S2 Polyline", () => {
22+
const s2String =
23+
"AQMAAAA7_tIhjf_avysne_M5iOW__WHh1yJy4z9MIcUK8Pvav7QbiLMRiuW_SWY5Y1lx4z-CIIuaN__av1Eb3-fUh-W_iPpHZ7By4z8=";
24+
const decoded = parsePolylineInput(s2String);
25+
26+
expect(decoded).toHaveLength(EXPECTED_POINTS.length);
27+
for (let i = 0; i < EXPECTED_POINTS.length; i++) {
28+
expect(decoded[i].latitude).toBeCloseTo(EXPECTED_POINTS[i].latitude, 5);
29+
expect(decoded[i].longitude).toBeCloseTo(EXPECTED_POINTS[i].longitude, 5);
30+
}
31+
});
32+
33+
test("decodes a GMP Unencoded Polyline (Plain Text)", () => {
34+
const input =
35+
"{ latitude: 37.42213, longitude: -122.0848 }, { latitude: 37.4152, longitude: -122.0627 }, { latitude: 37.427, longitude: -122.0854 }";
36+
const decoded = parsePolylineInput(input);
37+
38+
expect(decoded).toHaveLength(EXPECTED_POINTS.length);
39+
for (let i = 0; i < EXPECTED_POINTS.length; i++) {
40+
expect(decoded[i].latitude).toBe(EXPECTED_POINTS[i].latitude);
41+
expect(decoded[i].longitude).toBe(EXPECTED_POINTS[i].longitude);
42+
}
43+
});
44+
45+
test("decodes a JSON Polyline", () => {
46+
const input = JSON.stringify(EXPECTED_POINTS);
47+
const decoded = parsePolylineInput(input);
48+
49+
expect(decoded).toHaveLength(EXPECTED_POINTS.length);
50+
for (let i = 0; i < EXPECTED_POINTS.length; i++) {
51+
expect(decoded[i].latitude).toBe(EXPECTED_POINTS[i].latitude);
52+
expect(decoded[i].longitude).toBe(EXPECTED_POINTS[i].longitude);
53+
}
54+
});
55+
56+
test("throws error on invalid input", () => {
57+
expect(() => parsePolylineInput("not a polyline")).toThrow();
58+
});
59+
});

0 commit comments

Comments
 (0)