Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 55 additions & 3 deletions src/Map.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
`<svg xmlns="http://www.w3.org/2000/svg" width="120" height="30">
<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>
</svg>`
)}`,
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(() => {
Expand Down Expand Up @@ -635,6 +686,7 @@ function MapComponent({
<div ref={mapDivRef} id="map" style={{ height: "100%", width: "100%" }} />
{showPolylineUI && (
<PolylineCreation
map={mapRef.current}
onSubmit={handlePolylineSubmit}
onClose={() => setShowPolylineUI(false)}
buttonPosition={buttonPosition}
Expand Down
188 changes: 178 additions & 10 deletions src/PolylineCreation.js
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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 (
<div
style={{
Expand Down Expand Up @@ -81,12 +183,78 @@ Or paste an encoded S2 or Google Maps polyline string`;
/>
</label>
</div>
<button type="submit" className="map-button inner-button">
Create Polyline
</button>
<button type="button" className="map-button inner-button" onClick={onClose}>
Close
</button>
<div style={{ margin: "5px" }}>
<label>Distance Marker:</label>
<div style={{ display: "flex", gap: "10px", marginTop: "5px" }}>
<label>
<input
type="radio"
value="metric"
checked={distanceUnit === "metric"}
onChange={(e) => setDistanceUnit(e.target.value)}
/>
Metric
</label>
<label>
<input
type="radio"
value="imperial"
checked={distanceUnit === "imperial"}
onChange={(e) => setDistanceUnit(e.target.value)}
/>
Imperial
</label>
<label>
<input
type="radio"
value="none"
checked={distanceUnit === "none"}
onChange={(e) => setDistanceUnit(e.target.value)}
/>
None
</label>
</div>
</div>
<div style={{ margin: "5px", padding: "10px", backgroundColor: "#f5f5f5", borderRadius: "5px" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "5px" }}>
<label>
<input type="checkbox" checked={isMeasuring} onChange={(e) => setIsMeasuring(e.target.checked)} />
Enable Map Clicking (Measure)
</label>
{isMeasuring && <span style={{ fontSize: "12px", color: "#666" }}>Points: {points.length}</span>}
</div>
{isMeasuring && points.length > 1 && (
<div style={{ marginTop: "5px", display: "flex", justifyContent: "space-between" }}>
<span style={{ fontWeight: "bold", color: "#222" }}>{metric}</span>
<span style={{ fontWeight: "bold", color: "#222" }}>{imperial}</span>
</div>
)}
</div>
<div style={{ display: "flex", gap: "5px", marginTop: "10px" }}>
{isMeasuring ? (
<button
type="button"
className="map-button inner-button"
onClick={handleCreateFromMeasure}
style={{ flex: 1 }}
disabled={points.length < 2}
>
Create Polyline
</button>
) : (
<button
type="submit"
className="map-button inner-button"
style={{ flex: 1 }}
disabled={input.trim() === ""}
>
Create Polyline
</button>
)}
<button type="button" className="map-button inner-button" onClick={onClose} style={{ flex: 1 }}>
Close
</button>
</div>
</form>
</div>
);
Expand Down
18 changes: 18 additions & 0 deletions src/PolylineUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
54 changes: 53 additions & 1 deletion src/PolylineUtils.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parsePolylineInput } from "./PolylineUtils";
import { parsePolylineInput, calculatePolylineDistanceMeters } from "./PolylineUtils";
import { formatDistance } from "./Utils";

describe("PolylineUtils", () => {
const EXPECTED_POINTS = [
Expand Down Expand Up @@ -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");
});
});
Loading
Loading