From db698ec25653ef9ed8cc08094f079662a7897ad5 Mon Sep 17 00:00:00 2001
From: regeter <2320305+regeter@users.noreply.github.com>
Date: Wed, 4 Mar 2026 21:28:50 -0800
Subject: [PATCH] feat: Measure distances using polyline
---
src/Map.js | 58 +++++++++++-
src/PolylineCreation.js | 188 ++++++++++++++++++++++++++++++++++++--
src/PolylineUtils.js | 18 ++++
src/PolylineUtils.test.js | 54 ++++++++++-
src/Utils.js | 16 ++++
5 files changed, 320 insertions(+), 14 deletions(-)
diff --git a/src/Map.js b/src/Map.js
index d4bc89a..6ce2224 100644
--- a/src/Map.js
+++ b/src/Map.js
@@ -3,12 +3,13 @@ import { useEffect, useRef, useState, useMemo, useCallback } from "react";
import { APIProvider, useMapsLibrary } from "@vis.gl/react-google-maps";
import _ from "lodash";
import { getQueryStringValue, setQueryStringValue } from "./queryString";
-import { log } from "./Utils";
+import { log, formatDistance } from "./Utils";
import PolylineCreation from "./PolylineCreation";
import { decode } from "s2polyline-ts";
import TrafficPolyline from "./TrafficPolyline";
import { TripObjects } from "./TripObjects";
import { getToggleHandlers } from "./MapToggles.js";
+import { toast } from "react-toastify";
import { LEGEND_HTML } from "./LegendContent.js";
function MapComponent({
@@ -158,10 +159,10 @@ function MapComponent({
// Add UI Controls
const polylineButton = document.createElement("button");
- polylineButton.textContent = "Add Polyline";
+ polylineButton.textContent = "Measure";
polylineButton.className = "map-button";
polylineButton.onclick = (event) => {
- log("Add Polyline button clicked.");
+ log("Measure Polyline button clicked.");
const rect = event.target.getBoundingClientRect();
setButtonPosition({ top: rect.bottom, left: rect.left });
setShowPolylineUI((prev) => !prev);
@@ -316,6 +317,56 @@ function MapComponent({
});
newPolyline.setMap(map);
setPolylines((prev) => [...prev, newPolyline]);
+
+ let distanceMarker = null;
+
+ if (properties.distanceUnit !== "none" && properties.distanceMeters) {
+ let midPoint;
+ if (path.length === 2 && window.google?.maps?.geometry?.spherical) {
+ midPoint = window.google.maps.geometry.spherical.interpolate(path[0], path[1], 0.5);
+ } else {
+ midPoint = path[Math.floor(path.length / 2)];
+ }
+
+ const formatted = formatDistance(properties.distanceMeters);
+ const textToRender = properties.distanceUnit === "imperial" ? formatted.imperial : formatted.metric;
+
+ const svgIcon = {
+ url: `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(
+ ``
+ )}`,
+ anchor: new window.google.maps.Point(60, 15),
+ };
+
+ distanceMarker = new window.google.maps.Marker({
+ position: midPoint,
+ map,
+ icon: svgIcon,
+ zIndex: 1000,
+ });
+ }
+
+ let deletePending = false;
+ let deleteTimeout = null;
+
+ const removeElements = () => {
+ if (deletePending) {
+ newPolyline.setMap(null);
+ if (distanceMarker) distanceMarker.setMap(null);
+ } else {
+ toast.info("Click the polyline again to delete it.");
+ deletePending = true;
+ clearTimeout(deleteTimeout);
+ deleteTimeout = setTimeout(() => {
+ deletePending = false;
+ }, 3000);
+ }
+ };
+
+ newPolyline.addListener("click", removeElements);
+ if (distanceMarker) distanceMarker.addListener("click", removeElements);
}, []);
const recenterOnVehicleWrapper = useCallback(() => {
@@ -635,6 +686,7 @@ function MapComponent({
{showPolylineUI && (
setShowPolylineUI(false)}
buttonPosition={buttonPosition}
diff --git a/src/PolylineCreation.js b/src/PolylineCreation.js
index 487152e..1f4301e 100644
--- a/src/PolylineCreation.js
+++ b/src/PolylineCreation.js
@@ -1,14 +1,20 @@
// src/PolylineCreation.js
-import { useState } from "react";
-import { log } from "./Utils";
-import { parsePolylineInput } from "./PolylineUtils";
+import { useState, useEffect, useRef } from "react";
+import { parsePolylineInput, calculatePolylineDistanceMeters } from "./PolylineUtils";
+import { log, formatDistance } from "./Utils";
-function PolylineCreation({ onSubmit, onClose, buttonPosition }) {
+function PolylineCreation({ map, onSubmit, onClose, buttonPosition }) {
const [input, setInput] = useState("");
const [opacity, setOpacity] = useState(0.7);
const [color, setColor] = useState("#FF0000");
const [strokeWeight, setStrokeWeight] = useState(6);
+ const [distanceUnit, setDistanceUnit] = useState("metric");
+
+ const [isMeasuring, setIsMeasuring] = useState(false);
+ const [points, setPoints] = useState([]);
+ const polylineRef = useRef(null);
+ const markersRef = useRef([]);
const handleSubmit = (e) => {
e.preventDefault();
@@ -28,6 +34,102 @@ function PolylineCreation({ onSubmit, onClose, buttonPosition }) {
Or paste an encoded S2 or Google Maps polyline string`;
+ useEffect(() => {
+ if (!map || !isMeasuring) return;
+
+ log("MeasureMode: Attaching click listener");
+ const clickListener = map.addListener("click", (e) => {
+ setPoints((prev) => [...prev, e.latLng]);
+ });
+
+ map.setOptions({ draggableCursor: "crosshair" });
+
+ return () => {
+ window.google.maps.event.removeListener(clickListener);
+ if (map) {
+ map.setOptions({ draggableCursor: null });
+ }
+ };
+ }, [map, isMeasuring]);
+
+ useEffect(() => {
+ if (!map || !isMeasuring) return;
+
+ if (!polylineRef.current) {
+ polylineRef.current = new window.google.maps.Polyline({
+ map,
+ path: points,
+ strokeColor: color,
+ strokeOpacity: opacity,
+ strokeWeight: strokeWeight,
+ geodesic: true,
+ });
+ } else {
+ polylineRef.current.setPath(points);
+ polylineRef.current.setOptions({ strokeColor: color, strokeOpacity: opacity, strokeWeight });
+ }
+
+ markersRef.current.forEach((m) => m.setMap(null));
+ markersRef.current = points.map((p, i) => {
+ let label = (i + 1).toString();
+
+ return new window.google.maps.Marker({
+ map,
+ position: p,
+ label: {
+ text: label,
+ color: "white",
+ fontSize: "12px",
+ fontWeight: "bold",
+ },
+ icon: {
+ path: window.google.maps.SymbolPath.CIRCLE,
+ scale: 10,
+ fillColor: color,
+ fillOpacity: 1,
+ strokeWeight: 2,
+ strokeColor: "#FFFFFF",
+ },
+ zIndex: 1000,
+ });
+ });
+ }, [points, map, isMeasuring, color, opacity, strokeWeight]);
+
+ useEffect(() => {
+ if (!isMeasuring) {
+ setPoints([]);
+ if (polylineRef.current) {
+ polylineRef.current.setMap(null);
+ polylineRef.current = null;
+ }
+ markersRef.current.forEach((m) => m.setMap(null));
+ markersRef.current = [];
+ }
+
+ return () => {
+ if (polylineRef.current) {
+ polylineRef.current.setMap(null);
+ }
+ markersRef.current.forEach((m) => m.setMap(null));
+ };
+ }, [isMeasuring]);
+
+ const handleCreateFromMeasure = () => {
+ if (points.length < 2) return;
+ const formattedPoints = points.map((p) => ({ latitude: p.lat(), longitude: p.lng() }));
+
+ const distanceMeters = calculatePolylineDistanceMeters(formattedPoints);
+
+ onSubmit(formattedPoints, { opacity, color, strokeWeight, distanceMeters, distanceUnit });
+ setIsMeasuring(false);
+ };
+
+ const distanceMeters =
+ points.length > 1
+ ? calculatePolylineDistanceMeters(points.map((p) => ({ latitude: p.lat(), longitude: p.lng() })))
+ : 0;
+ const { metric, imperial } = formatDistance(distanceMeters);
+
return (
-
-
+
+
+
+
+ {isMeasuring && Points: {points.length}}
+
+ {isMeasuring && points.length > 1 && (
+
+ {metric}
+ {imperial}
+
+ )}
+
+
+ {isMeasuring ? (
+
+ ) : (
+
+ )}
+
+
);
diff --git a/src/PolylineUtils.js b/src/PolylineUtils.js
index 7dd275b..9fa3528 100644
--- a/src/PolylineUtils.js
+++ b/src/PolylineUtils.js
@@ -107,3 +107,21 @@ export function parsePolylineInput(input) {
throw new Error("Invalid polyline format or no valid coordinates found.");
}
+
+/**
+ * Calculates the total distance of a polyline in meters.
+ * @param {Array<{latitude: number, longitude: number}>} points
+ * @returns {number} distance in meters
+ */
+export function calculatePolylineDistanceMeters(points) {
+ if (!points || points.length < 2) return 0;
+ let distanceMeters = 0;
+ for (let i = 0; i < points.length - 1; i++) {
+ const p1 = new window.google.maps.LatLng(points[i].latitude, points[i].longitude);
+ const p2 = new window.google.maps.LatLng(points[i + 1].latitude, points[i + 1].longitude);
+ if (window.google?.maps?.geometry?.spherical) {
+ distanceMeters += window.google.maps.geometry.spherical.computeDistanceBetween(p1, p2);
+ }
+ }
+ return distanceMeters;
+}
diff --git a/src/PolylineUtils.test.js b/src/PolylineUtils.test.js
index fa2f214..f338fc5 100644
--- a/src/PolylineUtils.test.js
+++ b/src/PolylineUtils.test.js
@@ -1,4 +1,5 @@
-import { parsePolylineInput } from "./PolylineUtils";
+import { parsePolylineInput, calculatePolylineDistanceMeters } from "./PolylineUtils";
+import { formatDistance } from "./Utils";
describe("PolylineUtils", () => {
const EXPECTED_POINTS = [
@@ -57,3 +58,54 @@ describe("PolylineUtils", () => {
expect(() => parsePolylineInput("not a polyline")).toThrow();
});
});
+
+describe("calculatePolylineDistanceMeters", () => {
+ test("returns 0 for empty or single point polylines", () => {
+ expect(calculatePolylineDistanceMeters([])).toBe(0);
+ expect(calculatePolylineDistanceMeters([{ latitude: 0, longitude: 0 }])).toBe(0);
+ });
+
+ test("calculates distance correctly", () => {
+ // Mock window.google.maps
+ global.window.google = {
+ maps: {
+ LatLng: class {
+ constructor(lat, lng) {
+ this.lat = lat;
+ this.lng = lng;
+ }
+ },
+ geometry: {
+ spherical: {
+ computeDistanceBetween: jest.fn().mockImplementation(() => 100), // Mock 100m for any two points
+ },
+ },
+ },
+ };
+
+ const points = [
+ { latitude: 0, longitude: 0 },
+ { latitude: 1, longitude: 1 },
+ { latitude: 2, longitude: 2 },
+ ];
+ // 2 segments, 100m each = 200m
+ expect(calculatePolylineDistanceMeters(points)).toBe(200);
+
+ // Cleanup
+ delete global.window.google;
+ });
+});
+
+describe("formatDistance", () => {
+ test("formats under 1000 meters correctly", () => {
+ const { metric, imperial } = formatDistance(500);
+ expect(metric).toBe("500.0 m");
+ expect(imperial).toBe("1640.4 ft");
+ });
+
+ test("formats over 1000 meters into km and miles", () => {
+ const { metric, imperial } = formatDistance(2500);
+ expect(metric).toBe("2.50 km");
+ expect(imperial).toBe("1.55 mi");
+ });
+});
diff --git a/src/Utils.js b/src/Utils.js
index 79543e2..960c3a5 100644
--- a/src/Utils.js
+++ b/src/Utils.js
@@ -143,4 +143,20 @@ export const log = (...args) => {
}
};
+/**
+ * Formats a distance in meters into a human-readable string (meters/km and feet/miles).
+ * @param {number} distanceMeters
+ * @returns {{ metric: string, imperial: string }}
+ */
+export function formatDistance(distanceMeters) {
+ const distanceFeet = distanceMeters * 3.28084;
+ const distanceMiles = distanceMeters * 0.000621371;
+
+ const metric = distanceMeters < 1000 ? distanceMeters.toFixed(1) + " m" : (distanceMeters / 1000).toFixed(2) + " km";
+
+ const imperial = distanceFeet < 5280 ? distanceFeet.toFixed(1) + " ft" : distanceMiles.toFixed(2) + " mi";
+
+ return { metric, imperial };
+}
+
export { Utils as default };