import { RULER_ICON } from "/js/icons.js"; const SOURCE_ID = "measure"; const LINE_LAYER_ID = "measure-line"; const FILL_LAYER_ID = "measure-fill"; const POINT_LAYER_ID = "measure-points"; const EARTH_RADIUS_M = 6378137; // WGS84 equatorial radius, matches turf.js's area formula // A distance/area measuring tool, matching the ruler button in NPS's own // map controls (Home/zoom/ruler, screenshot). Click to add vertices, // distance sums the haversine length of the resulting line; area uses the // standard spherical-excess polygon formula (Chamberlain & Duquette, the // same one turf.js's `area()` uses) rather than a flat-plane shoelace, // since a flat approximation drifts at the scale this app's park-sized // areas can reach. SI units throughout (m/km, m²/km²) — no imperial toggle. export class MeasureControl { constructor() { this._mode = "distance"; this._active = false; this._points = []; } onAdd(map) { this._map = map; this._container = document.createElement("div"); this._container.className = "maplibregl-ctrl maplibregl-ctrl-group measure-ctrl"; this._container.innerHTML = ` `; this._button = this._container.querySelector(".measure-ctrl__button"); this._panel = this._container.querySelector(".measure-panel"); this._result = this._container.querySelector(".measure-panel__result"); this._button.addEventListener("click", () => this._setActive(!this._active)); this._panel.querySelectorAll(".measure-panel__mode").forEach((button) => { button.addEventListener("click", () => this._setMode(button.dataset.mode)); }); this._panel.querySelector(".measure-panel__clear").addEventListener("click", () => this._clear()); this._onClick = (event) => { if (!this._active) return; this._points.push([event.lngLat.lng, event.lngLat.lat]); this._render(); }; map.on("click", this._onClick); // setStyle() (switching Online/Offline) tears down all sources/layers — // same re-add-on-reload pattern as radar.js/gpx.js. map.on("style.load", () => { if (this._active) this._addLayers(); }); return this._container; } onRemove() { this._map.off("click", this._onClick); this._removeLayers(); this._container.remove(); } getDefaultPosition() { return "top-left"; } _setActive(active) { this._active = active; this._button.classList.toggle("active", active); this._panel.hidden = !active; this._map.getCanvas().style.cursor = active ? "crosshair" : ""; if (active) { this._addLayers(); } else { this._clear(); } } _setMode(mode) { this._mode = mode; this._panel.querySelectorAll(".measure-panel__mode").forEach((button) => { button.classList.toggle("active", button.dataset.mode === mode); }); this._points = []; this._render(); } _clear() { this._points = []; this._render(); } _addLayers() { if (this._map.getSource(SOURCE_ID)) return; this._map.addSource(SOURCE_ID, { type: "geojson", data: emptyCollection() }); this._map.addLayer({ id: FILL_LAYER_ID, type: "fill", source: SOURCE_ID, filter: ["==", "$type", "Polygon"], paint: { "fill-color": "#1e5fbf", "fill-opacity": 0.15 }, }); this._map.addLayer({ id: LINE_LAYER_ID, type: "line", source: SOURCE_ID, filter: ["!=", "$type", "Point"], paint: { "line-color": "#1e5fbf", "line-width": 2 }, }); this._map.addLayer({ id: POINT_LAYER_ID, type: "circle", source: SOURCE_ID, filter: ["==", "$type", "Point"], paint: { "circle-radius": 4, "circle-color": "#fff", "circle-stroke-color": "#1e5fbf", "circle-stroke-width": 2 }, }); this._render(); } _removeLayers() { [FILL_LAYER_ID, LINE_LAYER_ID, POINT_LAYER_ID].forEach((id) => { if (this._map.getLayer(id)) this._map.removeLayer(id); }); if (this._map.getSource(SOURCE_ID)) this._map.removeSource(SOURCE_ID); } _render() { const source = this._map.getSource(SOURCE_ID); if (!source) return; const pointFeatures = this._points.map((coord) => ({ type: "Feature", geometry: { type: "Point", coordinates: coord } })); const shapeFeature = this._mode === "area" ? this._points.length >= 3 && { type: "Feature", geometry: { type: "Polygon", coordinates: [[...this._points, this._points[0]]] } } : this._points.length >= 2 && { type: "Feature", geometry: { type: "LineString", coordinates: this._points } }; source.setData({ type: "FeatureCollection", features: shapeFeature ? [shapeFeature, ...pointFeatures] : pointFeatures }); this._result.textContent = this._mode === "area" ? formatArea(polygonArea(this._points)) : formatDistance(lineLength(this._points)); } } function emptyCollection() { return { type: "FeatureCollection", features: [] }; } function haversine([lng1, lat1], [lng2, lat2]) { const rad = Math.PI / 180; const dLat = (lat2 - lat1) * rad; const dLng = (lng2 - lng1) * rad; const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.sin(dLng / 2) ** 2; return 2 * EARTH_RADIUS_M * Math.asin(Math.sqrt(a)); } function lineLength(points) { let total = 0; for (let i = 1; i < points.length; i++) total += haversine(points[i - 1], points[i]); return total; } // Spherical polygon area (Chamberlain & Duquette, 2007) — the same formula // turf.js's area() uses. A flat shoelace formula would drift noticeably at // the scale a park boundary can reach; this accounts for the sphere. function polygonArea(points) { if (points.length < 3) return 0; const rad = (deg) => (deg * Math.PI) / 180; let area = 0; const n = points.length; for (let i = 0; i < n; i++) { const [, lat] = points[i]; const [lng1] = points[(i + n - 1) % n]; const [lng2] = points[(i + 1) % n]; area += (rad(lng2) - rad(lng1)) * Math.sin(rad(lat)); } return Math.abs((area * EARTH_RADIUS_M * EARTH_RADIUS_M) / 2); } function formatDistance(meters) { if (meters === 0) return ""; return meters >= 1000 ? `${(meters / 1000).toFixed(2)} km` : `${Math.round(meters)} m`; } function formatArea(squareMeters) { if (squareMeters === 0) return ""; return squareMeters >= 1e6 ? `${(squareMeters / 1e6).toFixed(2)} km²` : `${Math.round(squareMeters)} m²`; }