From 896403999dda66135a2d18e045ad4903952698e9 Mon Sep 17 00:00:00 2001 From: regeter <2320305+regeter@users.noreply.github.com> Date: Thu, 15 Jan 2026 15:30:12 -0800 Subject: [PATCH 1/2] feat: implementation of polyline decoding for S2, GMP Encoded, and JSON formats --- src/Map.js | 8 ++- src/PolylineCreation.js | 48 ++--------------- src/PolylineUtils.js | 109 ++++++++++++++++++++++++++++++++++++++ src/PolylineUtils.test.js | 59 +++++++++++++++++++++ 4 files changed, 179 insertions(+), 45 deletions(-) create mode 100644 src/PolylineUtils.js create mode 100644 src/PolylineUtils.test.js diff --git a/src/Map.js b/src/Map.js index f2537504..a8148cfd 100644 --- a/src/Map.js +++ b/src/Map.js @@ -275,7 +275,13 @@ function MapComponent({ log("handlePolylineSubmit called."); const path = waypoints.map((wp) => new window.google.maps.LatLng(wp.latitude, wp.longitude)); - const newPolyline = new window.google.maps.Polyline({ path, geodesic: true, ...properties }); + const newPolyline = new window.google.maps.Polyline({ + path, + geodesic: true, + strokeColor: properties.color, + strokeOpacity: properties.opacity, + strokeWeight: properties.strokeWeight, + }); newPolyline.setMap(map); setPolylines((prev) => [...prev, newPolyline]); }, []); diff --git a/src/PolylineCreation.js b/src/PolylineCreation.js index da5273f3..487152ed 100644 --- a/src/PolylineCreation.js +++ b/src/PolylineCreation.js @@ -1,8 +1,8 @@ // src/PolylineCreation.js import { useState } from "react"; -import { decode } from "s2polyline-ts"; import { log } from "./Utils"; +import { parsePolylineInput } from "./PolylineUtils"; function PolylineCreation({ onSubmit, onClose, buttonPosition }) { const [input, setInput] = useState(""); @@ -13,60 +13,20 @@ function PolylineCreation({ onSubmit, onClose, buttonPosition }) { const handleSubmit = (e) => { e.preventDefault(); try { - const trimmedInput = input.trim(); - - // Check if input looks like an encoded polyline (single string without spaces) - if (/^[A-Za-z0-9+/=\-_]+$/.test(trimmedInput)) { - log("Attempting to decode S2 polyline:", trimmedInput); - const decodedPoints = decode(trimmedInput); - - if (decodedPoints && decodedPoints.length > 0) { - // Convert S2 points to our expected format - const validWaypoints = decodedPoints.map((point) => ({ - latitude: point.latDegrees(), - longitude: point.lngDegrees(), - })); - - log(`Decoded ${validWaypoints.length} points from S2 polyline`); - onSubmit(validWaypoints, { opacity, color, strokeWeight }); - setInput(""); - return; - } - } - - // Existing JSON parsing logic - const jsonString = trimmedInput.replace(/(\w+):/g, '"$1":').replace(/\s+/g, " "); - - const inputWithBrackets = jsonString.startsWith("[") && jsonString.endsWith("]") ? jsonString : `[${jsonString}]`; - - const waypoints = JSON.parse(inputWithBrackets); - - const validWaypoints = waypoints.filter( - (waypoint) => - typeof waypoint === "object" && - "latitude" in waypoint && - "longitude" in waypoint && - typeof waypoint.latitude === "number" && - typeof waypoint.longitude === "number" - ); - - if (validWaypoints.length === 0) { - throw new Error("No valid waypoints found"); - } - + const validWaypoints = parsePolylineInput(input); log(`Parsed ${validWaypoints.length} valid waypoints`); onSubmit(validWaypoints, { opacity, color, strokeWeight }); + setInput(""); } catch (error) { log("Invalid input format:", error); } - setInput(""); }; let placeholder = `Paste waypoints here: { latitude: 52.5163, longitude: 13.2399 }, { latitude: 52.5162, longitude: 13.2400 } -Or paste an encoded S2 polyline string`; +Or paste an encoded S2 or Google Maps polyline string`; return (
} + */ +export function decodeGooglePolyline(encoded) { + const points = []; + let index = 0, + len = encoded.length; + let lat = 0, + lng = 0; + + while (index < len) { + let b, + shift = 0, + result = 0; + do { + b = encoded.charCodeAt(index++) - 63; + result |= (b & 0x1f) << shift; + shift += 5; + } while (b >= 0x20); + const dlat = result & 1 ? ~(result >> 1) : result >> 1; + lat += dlat; + + shift = 0; + result = 0; + do { + b = encoded.charCodeAt(index++) - 63; + result |= (b & 0x1f) << shift; + shift += 5; + } while (b >= 0x20); + const dlng = result & 1 ? ~(result >> 1) : result >> 1; + lng += dlng; + + points.push({ + latitude: lat / 1e5, + longitude: lng / 1e5, + }); + } + + return points; +} + +/** + * Parses polyline input in various formats: S2, Google Encoded, JSON, or Plain Text. + * @param {string} input + * @returns {Array<{latitude: number, longitude: number}>} + */ +export function parsePolylineInput(input) { + const trimmedInput = input.trim(); + + // Check if it's obviously JSON or plain text coordinate list + const isJsonLike = (trimmedInput.startsWith("[") || trimmedInput.startsWith("{")) && trimmedInput.includes(":"); + + if (!isJsonLike) { + try { + // S2 strings usually don't have spaces or certain JSON characters + if (!trimmedInput.includes(" ") && !trimmedInput.includes('"')) { + const decodedPoints = decodeS2(trimmedInput); + if (decodedPoints && decodedPoints.length > 0) { + return decodedPoints.map((point) => ({ + latitude: point.latDegrees(), + longitude: point.lngDegrees(), + })); + } + } + } catch (e) { + // Continue to next format + } + + try { + // Sanity check: Google polylines shouldn't have spaces or newlines + if (!trimmedInput.includes("\n") && !trimmedInput.includes(" ")) { + const decodedPoints = decodeGooglePolyline(trimmedInput); + if (decodedPoints && decodedPoints.length > 0) { + return decodedPoints; + } + } + } catch (e) { + // Continue to next format + } + } + + try { + const jsonString = trimmedInput.replace(/(\w+):/g, '"$1":').replace(/\s+/g, " "); + const inputWithBrackets = jsonString.startsWith("[") && jsonString.endsWith("]") ? jsonString : `[${jsonString}]`; + const waypoints = JSON.parse(inputWithBrackets); + + const validWaypoints = waypoints.filter( + (waypoint) => + typeof waypoint === "object" && + "latitude" in waypoint && + "longitude" in waypoint && + typeof waypoint.latitude === "number" && + typeof waypoint.longitude === "number" + ); + + if (validWaypoints.length > 0) { + return validWaypoints; + } + } catch (e) { + // Fall through to error + } + + throw new Error("Invalid polyline format or no valid coordinates found."); +} diff --git a/src/PolylineUtils.test.js b/src/PolylineUtils.test.js new file mode 100644 index 00000000..fa2f214d --- /dev/null +++ b/src/PolylineUtils.test.js @@ -0,0 +1,59 @@ +import { parsePolylineInput } from "./PolylineUtils"; + +describe("PolylineUtils", () => { + const EXPECTED_POINTS = [ + { latitude: 37.42213, longitude: -122.0848 }, + { latitude: 37.4152, longitude: -122.0627 }, + { latitude: 37.427, longitude: -122.0854 }, + ]; + + test("decodes a GMP Encoded Polyline", () => { + const encoded = "i_lcF~tchVhj@ciCwhAzlC"; + const decoded = parsePolylineInput(encoded); + + expect(decoded).toHaveLength(EXPECTED_POINTS.length); + for (let i = 0; i < EXPECTED_POINTS.length; i++) { + expect(decoded[i].latitude).toBeCloseTo(EXPECTED_POINTS[i].latitude, 5); + expect(decoded[i].longitude).toBeCloseTo(EXPECTED_POINTS[i].longitude, 5); + } + }); + + test("decodes an S2 Polyline", () => { + const s2String = + "AQMAAAA7_tIhjf_avysne_M5iOW__WHh1yJy4z9MIcUK8Pvav7QbiLMRiuW_SWY5Y1lx4z-CIIuaN__av1Eb3-fUh-W_iPpHZ7By4z8="; + const decoded = parsePolylineInput(s2String); + + expect(decoded).toHaveLength(EXPECTED_POINTS.length); + for (let i = 0; i < EXPECTED_POINTS.length; i++) { + expect(decoded[i].latitude).toBeCloseTo(EXPECTED_POINTS[i].latitude, 5); + expect(decoded[i].longitude).toBeCloseTo(EXPECTED_POINTS[i].longitude, 5); + } + }); + + test("decodes a GMP Unencoded Polyline (Plain Text)", () => { + const input = + "{ latitude: 37.42213, longitude: -122.0848 }, { latitude: 37.4152, longitude: -122.0627 }, { latitude: 37.427, longitude: -122.0854 }"; + const decoded = parsePolylineInput(input); + + expect(decoded).toHaveLength(EXPECTED_POINTS.length); + for (let i = 0; i < EXPECTED_POINTS.length; i++) { + expect(decoded[i].latitude).toBe(EXPECTED_POINTS[i].latitude); + expect(decoded[i].longitude).toBe(EXPECTED_POINTS[i].longitude); + } + }); + + test("decodes a JSON Polyline", () => { + const input = JSON.stringify(EXPECTED_POINTS); + const decoded = parsePolylineInput(input); + + expect(decoded).toHaveLength(EXPECTED_POINTS.length); + for (let i = 0; i < EXPECTED_POINTS.length; i++) { + expect(decoded[i].latitude).toBe(EXPECTED_POINTS[i].latitude); + expect(decoded[i].longitude).toBe(EXPECTED_POINTS[i].longitude); + } + }); + + test("throws error on invalid input", () => { + expect(() => parsePolylineInput("not a polyline")).toThrow(); + }); +}); From a25433f9eb57eec5f160a0de5ba479fd7257e59f Mon Sep 17 00:00:00 2001 From: regeter <2320305+regeter@users.noreply.github.com> Date: Thu, 15 Jan 2026 15:31:37 -0800 Subject: [PATCH 2/2] build: demo file for internal as well --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9399d863..b6773aaa 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "scripts": { "start": "react-scripts start", "build": "react-scripts build", - "internal": "PUBLIC_URL=/fleet-debugger npm run build && rsync -avzhe ssh --progress --delete --inplace ./build/ ${CLOUDTOP}:${CLOUDPATH}", + "internal": "cp datasets/two-trips-bay-area.json public/data.json && PUBLIC_URL=/fleet-debugger npm run build && rsync -avzhe ssh --progress --delete --inplace ./build/ ${CLOUDTOP}:${CLOUDPATH}", "test": "react-scripts test", "eject": "react-scripts eject", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}\"",