Mixing the legacy "$type" filter with an expression (["get", "role"]) in the same filter logs as unsupported in newer MapLibre. Modernized all three measure layer filters to expression syntax; the vertex circle layer's filter simplifies to just checking "role" (it's only ever set on Point features to begin with, so the geometry-type check was redundant once "role" was added). Found while trying to reproduce a report that per-segment distance labels don't show on a longer, several-point measurement -- couldn't reproduce it locally or against production itself (both showed labels correctly on a fresh page load), so this may be unrelated; still worth fixing on its own.
244 lines
8.9 KiB
JavaScript
244 lines
8.9 KiB
JavaScript
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 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>
|
||
<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__clear" title="Clear" aria-label="Clear">×</button>
|
||
</div>
|
||
`;
|
||
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: ["==", ["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 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 } };
|
||
// 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²`;
|
||
}
|