Add a distance/area measuring tool
A ruler button in the top-left control stack, matching NPS's own Area/Distance ruler. Click to add vertices; distance sums haversine segment lengths, area uses the spherical-excess polygon formula (same one turf.js's area() uses) rather than a flat shoelace, since a flat approximation drifts at park-boundary scale. SI units only (m/km, m²/km²) — no imperial toggle.
This commit is contained in:
1 parent
16d36e4441
commit
b6a9c1546e
4 files changed
+281
-2
No files matched your search
@@ -472,6 +472,82 @@ html, body {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.measure-ctrl {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.measure-ctrl__button svg {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.measure-ctrl__button.active {
|
||||||
|
background: #dbe9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.measure-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 100%;
|
||||||
|
margin-left: 8px;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: max-content;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.measure-panel[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.measure-panel__mode {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.measure-panel__mode:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.measure-panel__mode.active {
|
||||||
|
background: #dbe9ff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.measure-panel__result {
|
||||||
|
min-width: 4em;
|
||||||
|
color: #333;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.measure-panel__clear {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.measure-panel__clear:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
.zoom-level-ctrl__value {
|
.zoom-level-ctrl__value {
|
||||||
width: 29px;
|
width: 29px;
|
||||||
height: 29px;
|
height: 29px;
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ export const GNSS_ICON = `
|
|||||||
<path d="M12 2v3M12 19v3M2 12h3M19 12h3"/>
|
<path d="M12 2v3M12 19v3M2 12h3M19 12h3"/>
|
||||||
</svg>`;
|
</svg>`;
|
||||||
|
|
||||||
|
export const RULER_ICON = `
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round">
|
||||||
|
<path d="M4 15 15 4l5 5-11 11-5-5z"/>
|
||||||
|
<path d="M9 10l1.5 1.5M12 7l1.5 1.5M15 4l1.5 1.5M6 13l1.5 1.5"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
export const NEW_FOLDER_ICON = `
|
export const NEW_FOLDER_ICON = `
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/>
|
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/>
|
||||||
|
|||||||
+5
-2
@@ -10,6 +10,7 @@ import { GpxPanelControl } from "/js/gpx.js";
|
|||||||
import { GnssControl } from "/js/gnss.js";
|
import { GnssControl } from "/js/gnss.js";
|
||||||
import { LiveTrackPanelControl } from "/js/livetrack.js";
|
import { LiveTrackPanelControl } from "/js/livetrack.js";
|
||||||
import { TrackProfilePanel } from "/js/track_profile.js";
|
import { TrackProfilePanel } from "/js/track_profile.js";
|
||||||
|
import { MeasureControl } from "/js/measure.js";
|
||||||
import { LAYERS_ICON } from "/js/icons.js";
|
import { LAYERS_ICON } from "/js/icons.js";
|
||||||
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
|
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
|
||||||
import HomeButton from "/vendor/npmap-home-button/home-button.esm.js";
|
import HomeButton from "/vendor/npmap-home-button/home-button.esm.js";
|
||||||
@@ -228,8 +229,9 @@ const map = new MaplibreMap({
|
|||||||
hash: "map",
|
hash: "map",
|
||||||
});
|
});
|
||||||
|
|
||||||
// A compact top-left stack — Home, zoom, zoom-level readout, locate-me,
|
// A compact top-left stack — Home, zoom, zoom-level readout, measure,
|
||||||
// base map — matching the National Park Service's own map control layout,
|
// locate-me, base map — matching the National Park Service's own map
|
||||||
|
// control layout,
|
||||||
// all grouped together rather than split across corners. Added in this
|
// all grouped together rather than split across corners. Added in this
|
||||||
// order since MapLibre stacks same-position controls in add order.
|
// order since MapLibre stacks same-position controls in add order.
|
||||||
//
|
//
|
||||||
@@ -245,6 +247,7 @@ map.addControl(new HomeButton({ homePosition: CENTER, zoom: ZOOM }), "top-left")
|
|||||||
// (NavigationControl's default third button) would just be dead weight.
|
// (NavigationControl's default third button) would just be dead weight.
|
||||||
map.addControl(new NavigationControl({ showCompass: false }), "top-left");
|
map.addControl(new NavigationControl({ showCompass: false }), "top-left");
|
||||||
map.addControl(new ZoomLevelControl(), "top-left");
|
map.addControl(new ZoomLevelControl(), "top-left");
|
||||||
|
map.addControl(new MeasureControl(), "top-left");
|
||||||
map.addControl(
|
map.addControl(
|
||||||
new GeolocateControl({
|
new GeolocateControl({
|
||||||
positionOptions: { enableHighAccuracy: true },
|
positionOptions: { enableHighAccuracy: true },
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
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 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: ["==", "$type", "Polygon"],
|
||||||
|
paint: { "fill-color": "#1e5fbf", "fill-opacity": 0.15 },
|
||||||
|
});
|
||||||
|
this._map.addLayer({
|
||||||
|
id: LINE_LAYER_ID,
|
||||||
|
type: "line",
|
||||||
|
source: SOURCE_ID,
|
||||||
|
filter: ["!=", "$type", "Point"],
|
||||||
|
paint: { "line-color": "#1e5fbf", "line-width": 2 },
|
||||||
|
});
|
||||||
|
this._map.addLayer({
|
||||||
|
id: POINT_LAYER_ID,
|
||||||
|
type: "circle",
|
||||||
|
source: SOURCE_ID,
|
||||||
|
filter: ["==", "$type", "Point"],
|
||||||
|
paint: { "circle-radius": 4, "circle-color": "#fff", "circle-stroke-color": "#1e5fbf", "circle-stroke-width": 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
this._render();
|
||||||
|
}
|
||||||
|
|
||||||
|
_removeLayers() {
|
||||||
|
[FILL_LAYER_ID, LINE_LAYER_ID, POINT_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", 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 } };
|
||||||
|
|
||||||
|
source.setData({ type: "FeatureCollection", features: shapeFeature ? [shapeFeature, ...pointFeatures] : pointFeatures });
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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²`;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user