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 = [];
this._selectedIndex = null; // which vertex Delete/Backspace acts on
this._draggingIndex = null;
this._suppressNextClick = false; // a mousedown on a vertex already handled this click; don't also add a new point
}
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;
// A mousedown on an existing vertex (below) already selected/started
// dragging it — without this, that same click would also land here
// and add a brand new point right on top of it.
if (this._suppressNextClick) {
this._suppressNextClick = false;
return;
}
this._selectedIndex = null;
this._points.push([event.lngLat.lng, event.lngLat.lat]);
this._render();
};
map.on("click", this._onClick);
// Vertex drag: mousedown on a point starts it, a map-wide mousemove
// follows the cursor while dragging, mouseup anywhere ends it — not
// scoped to the layer since the cursor is very likely to leave the
// (few-pixel) vertex circle mid-drag. preventDefault on the mousedown
// is what stops MapLibre's own drag-to-pan from also engaging for the
// same gesture (its documented way to opt out per-interaction).
this._onVertexMouseDown = (event) => {
if (!this._active || this._noteForm.isOpen) return;
const index = event.features[0]?.properties?.index;
if (index === undefined) return;
event.preventDefault();
this._draggingIndex = index;
this._selectedIndex = index;
this._suppressNextClick = true;
this._map.getCanvas().style.cursor = "grabbing";
this._render();
};
map.on("mousedown", POINT_LAYER_ID, this._onVertexMouseDown);
this._onMouseMove = (event) => {
if (this._draggingIndex === null) return;
this._points[this._draggingIndex] = [event.lngLat.lng, event.lngLat.lat];
this._render();
};
map.on("mousemove", this._onMouseMove);
this._onMouseUp = () => {
if (this._draggingIndex === null) return;
this._draggingIndex = null;
this._map.getCanvas().style.cursor = this._active ? "crosshair" : "";
};
map.on("mouseup", this._onMouseUp);
map.on("mouseenter", POINT_LAYER_ID, () => {
if (this._draggingIndex === null) this._map.getCanvas().style.cursor = "grab";
});
map.on("mouseleave", POINT_LAYER_ID, () => {
if (this._draggingIndex === null) this._map.getCanvas().style.cursor = this._active ? "crosshair" : "";
});
this._onKeyDown = (event) => {
if (!this._active || this._selectedIndex === null) return;
if (event.key !== "Delete" && event.key !== "Backspace") return;
// Don't eat Backspace while the note-text form (or anything else on
// the page) actually has focus for typing.
if (["INPUT", "TEXTAREA"].includes(document.activeElement?.tagName)) return;
event.preventDefault();
this._points.splice(this._selectedIndex, 1);
this._selectedIndex = null;
this._render();
};
document.addEventListener("keydown", this._onKeyDown);
// 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._map.off("mousedown", POINT_LAYER_ID, this._onVertexMouseDown);
this._map.off("mousemove", this._onMouseMove);
this._map.off("mouseup", this._onMouseUp);
document.removeEventListener("keydown", this._onKeyDown);
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._selectedIndex = null;
this._render();
}
_clear() {
this._points = [];
this._selectedIndex = null;
this._draggingIndex = null;
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": ["case", ["get", "selected"], 6, 4],
"circle-color": ["case", ["get", "selected"], "#ffd43b", "#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, index) => ({
type: "Feature",
properties: { role: "vertex", index, selected: index === this._selectedIndex },
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²`;
}