Files
maptool/public/js/measure.js
T
randy b0576f3725
CI / check (push) Successful in 40s
CI / deploy (push) Successful in 12s
Make Measure's vertices draggable and deletable
Drag: mousedown on a vertex circle calls event.preventDefault() (the
documented way to opt a gesture out of MapLibre's own drag-to-pan),
then a map-wide mousemove/mouseup follows the cursor and updates that
point's coordinates live. Delete: clicking a vertex selects it (shown
larger/yellow); Delete or Backspace removes it, guarded against
eating a keystroke meant for an actual text input on the page.

A vertex's own mousedown sets a one-shot flag so the same click
doesn't also fall through to the generic map-click handler and add a
brand new point on top of it.
2026-09-09 12:47:44 +02:00

359 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = `
<button type="button" class="measure-ctrl__button" title="Measure" aria-label="Measure">${RULER_ICON}</button>
<div class="measure-panel" hidden>
<div class="measure-panel__controls">
<button type="button" class="measure-panel__mode active" data-mode="distance">Distance</button>
<button type="button" class="measure-panel__mode" data-mode="area">Area</button>
<span class="measure-panel__result"></span>
<button type="button" class="measure-panel__save" title="Save as a note" aria-label="Save as a note" disabled>📍</button>
<button type="button" class="measure-panel__clear" title="Clear" aria-label="Clear">×</button>
</div>
</div>
`;
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²`;
}