import { RULER_ICON } from "/js/icons.js"; import { NoteSaveForm } from "/js/notes.js"; import { isLoggedIn } from "/js/auth.js"; const SOURCE_ID = "measure"; const LINE_LAYER_ID = "measure-line"; const FILL_LAYER_ID = "measure-fill"; const POINT_LAYER_ID = "measure-points"; const SEGMENT_LABEL_LAYER_ID = "measure-segment-labels"; 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._controls = this._container.querySelector(".measure-panel__controls"); this._result = this._container.querySelector(".measure-panel__result"); this._saveButton = this._container.querySelector(".measure-panel__save"); // Same "hide the write-only affordance for a logged-out viewer" story // as notes.js's own button not being added at all — Measure itself // stays available either way, only saving needs a login. if (!isLoggedIn()) this._saveButton.hidden = true; this._noteForm = new NoteSaveForm(this._panel, { onSaved: () => { this._controls.hidden = false; this._clear(); }, onCancel: () => (this._controls.hidden = false), }); 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._saveButton.addEventListener("click", () => this._saveAsNote()); this._panel.querySelector(".measure-panel__clear").addEventListener("click", () => this._clear()); this._onClick = (event) => { if (!this._active || this._noteForm.isOpen) 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._noteForm.close(); this._controls.hidden = false; this._clear(); } } // The same shape _render() draws — a distance line (2+ points) or a // closed area polygon (3+ points), or null while there isn't yet enough // to measure — extracted so "Save as note" saves exactly what's on // screen rather than recomputing it slightly differently. _currentShapeGeometry() { if (this._mode === "area") { return this._points.length >= 3 ? { type: "Polygon", coordinates: [[...this._points, this._points[0]]] } : null; } return this._points.length >= 2 ? { type: "LineString", coordinates: this._points } : null; } _saveAsNote() { const geometry = this._currentShapeGeometry(); if (!geometry) return; this._controls.hidden = true; this._noteForm.open(geometry); } _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: ["==", ["geometry-type"], "Polygon"], paint: { "fill-color": "#1e5fbf", "fill-opacity": 0.15 }, }); this._map.addLayer({ id: LINE_LAYER_ID, type: "line", source: SOURCE_ID, filter: ["!=", ["geometry-type"], "Point"], paint: { "line-color": "#1e5fbf", "line-width": 2 }, }); // "role" alone (not also a geometry-type check) — it's only ever set on // Point features to begin with (the line/polygon shape feature has no // "role" property at all), so it already implies the geometry type. // Mixing the legacy "$type" filter syntax with an expression like // ["get", "role"] in the same filter logs as unsupported in newer // MapLibre — this sidesteps that rather than fixing it by switching // "$type" to ["geometry-type"] and keeping both checks. this._map.addLayer({ id: POINT_LAYER_ID, type: "circle", source: SOURCE_ID, filter: ["==", ["get", "role"], "vertex"], paint: { "circle-radius": 4, "circle-color": "#fff", "circle-stroke-color": "#1e5fbf", "circle-stroke-width": 2 }, }); // Distance mode only (see _render) — each segment's own length, not // just the running total, matching how a typical measure tool // (Google Maps, QGIS) labels every leg of a multi-point line. this._map.addLayer({ id: SEGMENT_LABEL_LAYER_ID, type: "symbol", source: SOURCE_ID, filter: ["==", ["get", "role"], "segment-label"], layout: { "text-field": ["get", "label"], "text-font": ["Noto Sans Regular"], "text-size": 11, "text-allow-overlap": true, }, paint: { "text-color": "#1e5fbf", "text-halo-color": "#fff", "text-halo-width": 1.5 }, }); this._render(); } _removeLayers() { [FILL_LAYER_ID, LINE_LAYER_ID, POINT_LAYER_ID, SEGMENT_LABEL_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", properties: { role: "vertex" }, geometry: { type: "Point", coordinates: coord }, })); const geometry = this._currentShapeGeometry(); const shapeFeature = geometry && { type: "Feature", geometry }; this._saveButton.disabled = !geometry; // Area mode has no equivalent yet — only asked for distance's own // per-leg labels, and a polygon's edges read differently (perimeter // segments, not a path) if this ever extends there. const segmentLabels = this._mode === "distance" ? this._points.slice(1).map((point, i) => ({ type: "Feature", properties: { role: "segment-label", label: formatDistance(haversine(this._points[i], point)) }, geometry: { type: "Point", coordinates: midpoint(this._points[i], point) }, })) : []; source.setData({ type: "FeatureCollection", features: [...(shapeFeature ? [shapeFeature] : []), ...pointFeatures, ...segmentLabels], }); 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; } // A plain coordinate average, not the geodesic midpoint — this only // positions a text label, and the two differ by nothing worth computing a // great-circle formula for at the segment lengths this tool measures. function midpoint([lng1, lat1], [lng2, lat2]) { return [(lng1 + lng2) / 2, (lat1 + lat2) / 2]; } // 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²`; }