Skip to content

Commit 5c21972

Browse files
authored
feat: Measure distances using polyline (#295)
1 parent 440510a commit 5c21972

File tree

5 files changed

+320
-14
lines changed

5 files changed

+320
-14
lines changed

src/Map.js

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,13 @@ import { useEffect, useRef, useState, useMemo, useCallback } from "react";
33
import { APIProvider, useMapsLibrary } from "@vis.gl/react-google-maps";
44
import _ from "lodash";
55
import { getQueryStringValue, setQueryStringValue } from "./queryString";
6-
import { log } from "./Utils";
6+
import { log, formatDistance } from "./Utils";
77
import PolylineCreation from "./PolylineCreation";
88
import { decode } from "s2polyline-ts";
99
import TrafficPolyline from "./TrafficPolyline";
1010
import { TripObjects } from "./TripObjects";
1111
import { getToggleHandlers } from "./MapToggles.js";
12+
import { toast } from "react-toastify";
1213
import { LEGEND_HTML } from "./LegendContent.js";
1314

1415
function MapComponent({
@@ -158,10 +159,10 @@ function MapComponent({
158159

159160
// Add UI Controls
160161
const polylineButton = document.createElement("button");
161-
polylineButton.textContent = "Add Polyline";
162+
polylineButton.textContent = "Measure";
162163
polylineButton.className = "map-button";
163164
polylineButton.onclick = (event) => {
164-
log("Add Polyline button clicked.");
165+
log("Measure Polyline button clicked.");
165166
const rect = event.target.getBoundingClientRect();
166167
setButtonPosition({ top: rect.bottom, left: rect.left });
167168
setShowPolylineUI((prev) => !prev);
@@ -316,6 +317,56 @@ function MapComponent({
316317
});
317318
newPolyline.setMap(map);
318319
setPolylines((prev) => [...prev, newPolyline]);
320+
321+
let distanceMarker = null;
322+
323+
if (properties.distanceUnit !== "none" && properties.distanceMeters) {
324+
let midPoint;
325+
if (path.length === 2 && window.google?.maps?.geometry?.spherical) {
326+
midPoint = window.google.maps.geometry.spherical.interpolate(path[0], path[1], 0.5);
327+
} else {
328+
midPoint = path[Math.floor(path.length / 2)];
329+
}
330+
331+
const formatted = formatDistance(properties.distanceMeters);
332+
const textToRender = properties.distanceUnit === "imperial" ? formatted.imperial : formatted.metric;
333+
334+
const svgIcon = {
335+
url: `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(
336+
`<svg xmlns="http://www.w3.org/2000/svg" width="120" height="30">
337+
<text x="60" y="15" dominant-baseline="central" text-anchor="middle" font-family="sans-serif" font-size="12px" font-weight="bold" fill="${properties.color}" stroke="white" stroke-width="3" paint-order="stroke">${textToRender}</text>
338+
</svg>`
339+
)}`,
340+
anchor: new window.google.maps.Point(60, 15),
341+
};
342+
343+
distanceMarker = new window.google.maps.Marker({
344+
position: midPoint,
345+
map,
346+
icon: svgIcon,
347+
zIndex: 1000,
348+
});
349+
}
350+
351+
let deletePending = false;
352+
let deleteTimeout = null;
353+
354+
const removeElements = () => {
355+
if (deletePending) {
356+
newPolyline.setMap(null);
357+
if (distanceMarker) distanceMarker.setMap(null);
358+
} else {
359+
toast.info("Click the polyline again to delete it.");
360+
deletePending = true;
361+
clearTimeout(deleteTimeout);
362+
deleteTimeout = setTimeout(() => {
363+
deletePending = false;
364+
}, 3000);
365+
}
366+
};
367+
368+
newPolyline.addListener("click", removeElements);
369+
if (distanceMarker) distanceMarker.addListener("click", removeElements);
319370
}, []);
320371

321372
const recenterOnVehicleWrapper = useCallback(() => {
@@ -635,6 +686,7 @@ function MapComponent({
635686
<div ref={mapDivRef} id="map" style={{ height: "100%", width: "100%" }} />
636687
{showPolylineUI && (
637688
<PolylineCreation
689+
map={mapRef.current}
638690
onSubmit={handlePolylineSubmit}
639691
onClose={() => setShowPolylineUI(false)}
640692
buttonPosition={buttonPosition}

src/PolylineCreation.js

Lines changed: 178 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
// src/PolylineCreation.js
22

3-
import { useState } from "react";
4-
import { log } from "./Utils";
5-
import { parsePolylineInput } from "./PolylineUtils";
3+
import { useState, useEffect, useRef } from "react";
4+
import { parsePolylineInput, calculatePolylineDistanceMeters } from "./PolylineUtils";
5+
import { log, formatDistance } from "./Utils";
66

7-
function PolylineCreation({ onSubmit, onClose, buttonPosition }) {
7+
function PolylineCreation({ map, onSubmit, onClose, buttonPosition }) {
88
const [input, setInput] = useState("");
99
const [opacity, setOpacity] = useState(0.7);
1010
const [color, setColor] = useState("#FF0000");
1111
const [strokeWeight, setStrokeWeight] = useState(6);
12+
const [distanceUnit, setDistanceUnit] = useState("metric");
13+
14+
const [isMeasuring, setIsMeasuring] = useState(false);
15+
const [points, setPoints] = useState([]);
16+
const polylineRef = useRef(null);
17+
const markersRef = useRef([]);
1218

1319
const handleSubmit = (e) => {
1420
e.preventDefault();
@@ -28,6 +34,102 @@ function PolylineCreation({ onSubmit, onClose, buttonPosition }) {
2834
2935
Or paste an encoded S2 or Google Maps polyline string`;
3036

37+
useEffect(() => {
38+
if (!map || !isMeasuring) return;
39+
40+
log("MeasureMode: Attaching click listener");
41+
const clickListener = map.addListener("click", (e) => {
42+
setPoints((prev) => [...prev, e.latLng]);
43+
});
44+
45+
map.setOptions({ draggableCursor: "crosshair" });
46+
47+
return () => {
48+
window.google.maps.event.removeListener(clickListener);
49+
if (map) {
50+
map.setOptions({ draggableCursor: null });
51+
}
52+
};
53+
}, [map, isMeasuring]);
54+
55+
useEffect(() => {
56+
if (!map || !isMeasuring) return;
57+
58+
if (!polylineRef.current) {
59+
polylineRef.current = new window.google.maps.Polyline({
60+
map,
61+
path: points,
62+
strokeColor: color,
63+
strokeOpacity: opacity,
64+
strokeWeight: strokeWeight,
65+
geodesic: true,
66+
});
67+
} else {
68+
polylineRef.current.setPath(points);
69+
polylineRef.current.setOptions({ strokeColor: color, strokeOpacity: opacity, strokeWeight });
70+
}
71+
72+
markersRef.current.forEach((m) => m.setMap(null));
73+
markersRef.current = points.map((p, i) => {
74+
let label = (i + 1).toString();
75+
76+
return new window.google.maps.Marker({
77+
map,
78+
position: p,
79+
label: {
80+
text: label,
81+
color: "white",
82+
fontSize: "12px",
83+
fontWeight: "bold",
84+
},
85+
icon: {
86+
path: window.google.maps.SymbolPath.CIRCLE,
87+
scale: 10,
88+
fillColor: color,
89+
fillOpacity: 1,
90+
strokeWeight: 2,
91+
strokeColor: "#FFFFFF",
92+
},
93+
zIndex: 1000,
94+
});
95+
});
96+
}, [points, map, isMeasuring, color, opacity, strokeWeight]);
97+
98+
useEffect(() => {
99+
if (!isMeasuring) {
100+
setPoints([]);
101+
if (polylineRef.current) {
102+
polylineRef.current.setMap(null);
103+
polylineRef.current = null;
104+
}
105+
markersRef.current.forEach((m) => m.setMap(null));
106+
markersRef.current = [];
107+
}
108+
109+
return () => {
110+
if (polylineRef.current) {
111+
polylineRef.current.setMap(null);
112+
}
113+
markersRef.current.forEach((m) => m.setMap(null));
114+
};
115+
}, [isMeasuring]);
116+
117+
const handleCreateFromMeasure = () => {
118+
if (points.length < 2) return;
119+
const formattedPoints = points.map((p) => ({ latitude: p.lat(), longitude: p.lng() }));
120+
121+
const distanceMeters = calculatePolylineDistanceMeters(formattedPoints);
122+
123+
onSubmit(formattedPoints, { opacity, color, strokeWeight, distanceMeters, distanceUnit });
124+
setIsMeasuring(false);
125+
};
126+
127+
const distanceMeters =
128+
points.length > 1
129+
? calculatePolylineDistanceMeters(points.map((p) => ({ latitude: p.lat(), longitude: p.lng() })))
130+
: 0;
131+
const { metric, imperial } = formatDistance(distanceMeters);
132+
31133
return (
32134
<div
33135
style={{
@@ -81,12 +183,78 @@ Or paste an encoded S2 or Google Maps polyline string`;
81183
/>
82184
</label>
83185
</div>
84-
<button type="submit" className="map-button inner-button">
85-
Create Polyline
86-
</button>
87-
<button type="button" className="map-button inner-button" onClick={onClose}>
88-
Close
89-
</button>
186+
<div style={{ margin: "5px" }}>
187+
<label>Distance Marker:</label>
188+
<div style={{ display: "flex", gap: "10px", marginTop: "5px" }}>
189+
<label>
190+
<input
191+
type="radio"
192+
value="metric"
193+
checked={distanceUnit === "metric"}
194+
onChange={(e) => setDistanceUnit(e.target.value)}
195+
/>
196+
Metric
197+
</label>
198+
<label>
199+
<input
200+
type="radio"
201+
value="imperial"
202+
checked={distanceUnit === "imperial"}
203+
onChange={(e) => setDistanceUnit(e.target.value)}
204+
/>
205+
Imperial
206+
</label>
207+
<label>
208+
<input
209+
type="radio"
210+
value="none"
211+
checked={distanceUnit === "none"}
212+
onChange={(e) => setDistanceUnit(e.target.value)}
213+
/>
214+
None
215+
</label>
216+
</div>
217+
</div>
218+
<div style={{ margin: "5px", padding: "10px", backgroundColor: "#f5f5f5", borderRadius: "5px" }}>
219+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "5px" }}>
220+
<label>
221+
<input type="checkbox" checked={isMeasuring} onChange={(e) => setIsMeasuring(e.target.checked)} />
222+
Enable Map Clicking (Measure)
223+
</label>
224+
{isMeasuring && <span style={{ fontSize: "12px", color: "#666" }}>Points: {points.length}</span>}
225+
</div>
226+
{isMeasuring && points.length > 1 && (
227+
<div style={{ marginTop: "5px", display: "flex", justifyContent: "space-between" }}>
228+
<span style={{ fontWeight: "bold", color: "#222" }}>{metric}</span>
229+
<span style={{ fontWeight: "bold", color: "#222" }}>{imperial}</span>
230+
</div>
231+
)}
232+
</div>
233+
<div style={{ display: "flex", gap: "5px", marginTop: "10px" }}>
234+
{isMeasuring ? (
235+
<button
236+
type="button"
237+
className="map-button inner-button"
238+
onClick={handleCreateFromMeasure}
239+
style={{ flex: 1 }}
240+
disabled={points.length < 2}
241+
>
242+
Create Polyline
243+
</button>
244+
) : (
245+
<button
246+
type="submit"
247+
className="map-button inner-button"
248+
style={{ flex: 1 }}
249+
disabled={input.trim() === ""}
250+
>
251+
Create Polyline
252+
</button>
253+
)}
254+
<button type="button" className="map-button inner-button" onClick={onClose} style={{ flex: 1 }}>
255+
Close
256+
</button>
257+
</div>
90258
</form>
91259
</div>
92260
);

src/PolylineUtils.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,21 @@ export function parsePolylineInput(input) {
107107

108108
throw new Error("Invalid polyline format or no valid coordinates found.");
109109
}
110+
111+
/**
112+
* Calculates the total distance of a polyline in meters.
113+
* @param {Array<{latitude: number, longitude: number}>} points
114+
* @returns {number} distance in meters
115+
*/
116+
export function calculatePolylineDistanceMeters(points) {
117+
if (!points || points.length < 2) return 0;
118+
let distanceMeters = 0;
119+
for (let i = 0; i < points.length - 1; i++) {
120+
const p1 = new window.google.maps.LatLng(points[i].latitude, points[i].longitude);
121+
const p2 = new window.google.maps.LatLng(points[i + 1].latitude, points[i + 1].longitude);
122+
if (window.google?.maps?.geometry?.spherical) {
123+
distanceMeters += window.google.maps.geometry.spherical.computeDistanceBetween(p1, p2);
124+
}
125+
}
126+
return distanceMeters;
127+
}

src/PolylineUtils.test.js

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { parsePolylineInput } from "./PolylineUtils";
1+
import { parsePolylineInput, calculatePolylineDistanceMeters } from "./PolylineUtils";
2+
import { formatDistance } from "./Utils";
23

34
describe("PolylineUtils", () => {
45
const EXPECTED_POINTS = [
@@ -57,3 +58,54 @@ describe("PolylineUtils", () => {
5758
expect(() => parsePolylineInput("not a polyline")).toThrow();
5859
});
5960
});
61+
62+
describe("calculatePolylineDistanceMeters", () => {
63+
test("returns 0 for empty or single point polylines", () => {
64+
expect(calculatePolylineDistanceMeters([])).toBe(0);
65+
expect(calculatePolylineDistanceMeters([{ latitude: 0, longitude: 0 }])).toBe(0);
66+
});
67+
68+
test("calculates distance correctly", () => {
69+
// Mock window.google.maps
70+
global.window.google = {
71+
maps: {
72+
LatLng: class {
73+
constructor(lat, lng) {
74+
this.lat = lat;
75+
this.lng = lng;
76+
}
77+
},
78+
geometry: {
79+
spherical: {
80+
computeDistanceBetween: jest.fn().mockImplementation(() => 100), // Mock 100m for any two points
81+
},
82+
},
83+
},
84+
};
85+
86+
const points = [
87+
{ latitude: 0, longitude: 0 },
88+
{ latitude: 1, longitude: 1 },
89+
{ latitude: 2, longitude: 2 },
90+
];
91+
// 2 segments, 100m each = 200m
92+
expect(calculatePolylineDistanceMeters(points)).toBe(200);
93+
94+
// Cleanup
95+
delete global.window.google;
96+
});
97+
});
98+
99+
describe("formatDistance", () => {
100+
test("formats under 1000 meters correctly", () => {
101+
const { metric, imperial } = formatDistance(500);
102+
expect(metric).toBe("500.0 m");
103+
expect(imperial).toBe("1640.4 ft");
104+
});
105+
106+
test("formats over 1000 meters into km and miles", () => {
107+
const { metric, imperial } = formatDistance(2500);
108+
expect(metric).toBe("2.50 km");
109+
expect(imperial).toBe("1.55 mi");
110+
});
111+
});

0 commit comments

Comments
 (0)