diff --git a/docs/architecture.adoc b/docs/architecture.adoc index 94b099a..fdc8202 100644 --- a/docs/architecture.adoc +++ b/docs/architecture.adoc @@ -172,6 +172,29 @@ from an unmerged upstream branch and patched for a MapLibre API break (it targeted MapLibre ~2.4's public `map.transform`, since removed) — see `docs/third-party-notices.adoc` for the exact patch. +=== Measuring distance and area + +`MeasureControl` (`public/js/measure.js`) opens a small floating +panel next to its ruler button (mirroring NPS's own Area/Distance +ruler) with Distance/Area mode buttons, a live result, and Clear — +its buttons needed `.measure-panel ` (not just the bare +class) to outrank `.maplibregl-ctrl-group button`'s own 29×29px/ +zero-padding rule, which otherwise clips "Distance"/"Area" into +overlapping text (the container is a `maplibregl-ctrl-group` for the +ruler button's own sizing, and that rule reaches every button nested +inside it, panel included). Clicking the map adds a vertex; distance +sums haversine segment lengths, area uses the spherical-excess +polygon formula (Chamberlain & Duquette — the same one turf.js's +`area()` uses) rather than a flat shoelace, since a flat approximation +drifts at the scale a park boundary can reach. In distance mode, each +individual segment also gets its own label at its midpoint (a `symbol` +layer keyed off a `role: "segment-label"` property, filtered apart +from the draggable vertex points, which share the same source but +`role: "vertex"`) — the running total in the panel doesn't say how +long any one leg was on its own. Area mode has no equivalent yet; +a polygon's edges are its perimeter, not a path, so the same treatment +may not even read the same way there. + === Debug inspector (Alt+click) Alt+click anywhere on the map opens a popup listing every rendered diff --git a/public/css/style.css b/public/css/style.css index 3faaedf..40d558e 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -537,7 +537,16 @@ html, body { display: none; } -.measure-panel__mode { +/* ".measure-panel " prefix (not just ".measure-panel__mode") to outrank + ".maplibregl-ctrl-group button" (higher specificity, since these buttons + sit inside a maplibregl-ctrl-group container for the ruler icon's own + sake) — its fixed 29x29 square + zero padding otherwise wins, clipping + "Distance"/"Area" into overlapping text instead of sizing to fit it. */ +.measure-panel .measure-panel__mode { + width: auto; + height: auto; + display: inline-block; + white-space: nowrap; padding: 4px 8px; border: none; border-radius: 4px; @@ -562,7 +571,10 @@ html, body { font-variant-numeric: tabular-nums; } -.measure-panel__clear { +.measure-panel .measure-panel__clear { + width: auto; + height: auto; + display: inline-block; border: none; background: none; font-size: 16px; diff --git a/public/js/measure.js b/public/js/measure.js index 8ea6520..ed7dd72 100644 --- a/public/js/measure.js +++ b/public/js/measure.js @@ -4,6 +4,7 @@ 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 @@ -118,15 +119,31 @@ export class MeasureControl { id: POINT_LAYER_ID, type: "circle", source: SOURCE_ID, - filter: ["==", "$type", "Point"], + filter: ["all", ["==", "$type", "Point"], ["==", ["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].forEach((id) => { + [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); @@ -136,13 +153,31 @@ export class MeasureControl { const source = this._map.getSource(SOURCE_ID); if (!source) return; - const pointFeatures = this._points.map((coord) => ({ type: "Feature", geometry: { type: "Point", coordinates: coord } })); + 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] : pointFeatures }); + source.setData({ + type: "FeatureCollection", + features: [...(shapeFeature ? [shapeFeature] : []), ...pointFeatures, ...segmentLabels], + }); this._result.textContent = this._mode === "area" ? formatArea(polygonArea(this._points)) : formatDistance(lineLength(this._points)); } @@ -166,6 +201,13 @@ function lineLength(points) { 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.