From b6a9c1546eaea291ba6b3813b6a41dc496fce15f Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Mon, 7 Sep 2026 22:15:08 +0200 Subject: [PATCH] Add a distance/area measuring tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- public/css/style.css | 76 +++++++++++++++++ public/js/icons.js | 6 ++ public/js/map.js | 7 +- public/js/measure.js | 194 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 public/js/measure.js diff --git a/public/css/style.css b/public/css/style.css index 2421d1b..c85b1bf 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -472,6 +472,82 @@ html, body { 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 { width: 29px; height: 29px; diff --git a/public/js/icons.js b/public/js/icons.js index b9f1ac2..3258a70 100644 --- a/public/js/icons.js +++ b/public/js/icons.js @@ -26,6 +26,12 @@ export const GNSS_ICON = ` `; +export const RULER_ICON = ` + + + +`; + export const NEW_FOLDER_ICON = ` diff --git a/public/js/map.js b/public/js/map.js index a26ef24..658fd5a 100644 --- a/public/js/map.js +++ b/public/js/map.js @@ -10,6 +10,7 @@ import { GpxPanelControl } from "/js/gpx.js"; import { GnssControl } from "/js/gnss.js"; import { LiveTrackPanelControl } from "/js/livetrack.js"; import { TrackProfilePanel } from "/js/track_profile.js"; +import { MeasureControl } from "/js/measure.js"; import { LAYERS_ICON } from "/js/icons.js"; import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js"; import HomeButton from "/vendor/npmap-home-button/home-button.esm.js"; @@ -228,8 +229,9 @@ const map = new MaplibreMap({ hash: "map", }); -// A compact top-left stack — Home, zoom, zoom-level readout, locate-me, -// base map — matching the National Park Service's own map control layout, +// A compact top-left stack — Home, zoom, zoom-level readout, measure, +// 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 // 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. map.addControl(new NavigationControl({ showCompass: false }), "top-left"); map.addControl(new ZoomLevelControl(), "top-left"); +map.addControl(new MeasureControl(), "top-left"); map.addControl( new GeolocateControl({ positionOptions: { enableHighAccuracy: true }, diff --git a/public/js/measure.js b/public/js/measure.js new file mode 100644 index 0000000..8ea6520 --- /dev/null +++ b/public/js/measure.js @@ -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 = ` + + + `; + 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²`; +}