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( + ` + ${textToRender} + ` + )}`, + 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 };