Files
maptool/public/js/measure.js
T
randy b5b91d0df5 Gate note-writing UI behind login
Adding a note (either tool) always ended in a 401 for a logged-out
viewer — the write-only affordance just wasn't hidden yet, unlike
GpxPanelControl's own upload/new-folder buttons, which already are.

NotesControl isn't added to the map at all when logged out (its whole
purpose is writing); MeasureControl's "Save as note" button is hidden
but the tool itself stays usable for plain measuring either way.
2026-09-09 12:44:52 +02:00

283 lines
11 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 = [];
}
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;
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²`;
}