Compare commits

...
3 Commits
Author SHA1 Message Date
randy 24b6180c03 Fix overlapping Distance/Area text; label each segment's own length
CI / deploy (push) Successful in 14s
CI / check (push) Successful in 45s
The measure panel's mode buttons sit inside a maplibregl-ctrl-group
container (for the ruler button's own 29x29 sizing) — its global
"button" rule reaches them too, clipping "Distance"/"Area" into a
fixed 29px square with zero padding instead of sizing to fit the
text. Fixed by upping specificity (".measure-panel .measure-panel__mode")
rather than fighting the cascade with !important.

In distance mode, each segment between two clicked points now shows
its own length at its midpoint, not just the running total in the
panel — a symbol layer keyed off the existing source, filtered apart
from the vertex circles by a new "role" property.

Verified both in a real browser (panel no longer overlaps; two
638m segments rendered and labeled correctly summing to the panel's
1.28km total).
2026-09-07 23:05:54 +02:00
randy 12635a48c3 Badge the exception (not yet cached) instead of the near-universal case
GpxStore#save converts eagerly, so "cached" is true for nearly every
file in the tree already — badging that was just noise. Flipped to
PENDING_ICON, shown only for the rare case: a .gpx/.fit dropped
straight into data/gpx/ on the filesystem, bypassing the app, and not
yet opened.

Also add an explicit download button next to delete (DOWNLOAD_ICON) —
the file name itself has been a download link since the last commit,
but isn't obviously clickable at a glance.
2026-09-07 22:59:02 +02:00
randy cd92dadf6a Let a cached file's GeoJSON be downloaded by clicking its tree name
Once a .gpx/.fit is cached, the tree shows it under its converted name
("ride.gpx" -> "ride.gpx.geojson", matching the real cache file next
to it on disk) instead of still reading as un-converted -- the
GeoJSON is what's actually on the map from that point on, and the
original is never deleted, just not re-shown as itself. The name is
now a real link (GET /api/gpx/file/<path>?download=1) instead of
plain text, the only way to get that converted form out of the app.

Verified in a real browser that an <a> nested in a <label> claims its
own click rather than the label forwarding it to the checkbox, so
downloading and toggling visibility don't fight over the same click.
2026-09-07 22:56:46 +02:00
7 changed files with 215 additions and 25 deletions

No files matched your search

+13
View File
@@ -191,6 +191,10 @@ module Maptool
json paths: paths, tree: GPX_STORE.tree
end
# ?download=1 exports the file's GeoJSON as an attachment instead of
# rendering it on the map — the only way to get a .gpx/.fit's converted
# form out of the app, since GpxStore#geojson's cache is otherwise an
# internal implementation detail with no filename of its own to expose.
get "/api/gpx/file/*" do
begin
body = GPX_STORE.geojson(params[:splat].first)
@@ -203,6 +207,15 @@ module Maptool
end
content_type :json
if params[:download]
# Matches the tree's own display name for a cached .gpx/.fit
# (public/js/gpx.js's _renderFile) — "ride.gpx" downloads as
# "ride.gpx.geojson", not "ride.geojson", so the two never disagree.
# A .geojson source is already named right and is left alone.
original_name = File.basename(params[:splat].first)
download_name = original_name.downcase.end_with?(".geojson") ? original_name : "#{original_name}.geojson"
attachment download_name
end
body
end
+48 -4
View File
@@ -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 <selector>` (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
@@ -282,10 +305,31 @@ multi-second hang on a checkbox click reads as broken in a way the
same wait during an upload doesn't. A file that fails to parse still
saves fine either way; the error just surfaces on first view instead.
A `.geojson` upload skips this path entirely — `geojson` reads and
returns it directly, no cache file, no `Gpx`/`Fit` involved; the file
tree's cached-file icon (`GEOJSON_ICON`, `public/js/icons.js`) always
shows for one, since there's no conversion step it could ever be
waiting on. `Gpx` parses the GPX with REXML: `<wpt>`
returns it directly, no cache file, no `Gpx`/`Fit` involved.
`GpxStore#save` converting eagerly means `cached` is `true` for
almost everything in the tree — the one exception is a `.gpx`/`.fit`
dropped straight into `data/gpx/` on the filesystem, bypassing the
app, and not yet opened. Badging the common case would just be noise,
so the file tree's icon (`PENDING_ICON`, `public/js/icons.js`) marks
that exception instead of the (near-universal) ready state.
+
Once a `.gpx`/`.fit` is cached, the tree shows it under its converted
name instead — `"ride.gpx"` reads as `"ride.gpx.geojson"`, matching
the real cache file sitting next to it on disk — since the GeoJSON,
not the original, is what's actually rendered on the map from that
point on. The original is never deleted or re-parsed once cached, just
not re-shown as itself. Two ways to get that converted form out of the
app as an actual file (`GET /api/gpx/file/<path>?download=1`) — the
cache is otherwise an implementation detail with no name of its own to
expose: the file name itself is a real link (an `<a>`, not a `<span>`,
specifically so clicking it downloads instead of also toggling the
row's checkbox — a native `<a>`/`<button>` nested inside a `<label>`
claims its own click instead of the label forwarding it to the
associated control, so the two behaviors don't fight over the same
click), and a dedicated download button sits next to delete for anyone
who wouldn't guess the name itself is clickable. A directly-uploaded
`.geojson` keeps its own name (nothing to convert) and downloads
unchanged either way. `Gpx` parses the GPX with REXML: `<wpt>`
waypoints become `Point` features (with `name`, `desc` and `time`
carried through as properties), each `<trk>` becomes one
`MultiLineString` feature (one line per `<trkseg>`), and each `<rte>`
+36 -2
View File
@@ -360,6 +360,12 @@ html, body {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: inherit;
text-decoration: none;
}
.gpx-tree__file-name:hover {
text-decoration: underline;
}
.gpx-tree__kind-icon {
@@ -373,6 +379,22 @@ html, body {
height: 14px;
}
.gpx-tree__download {
flex-shrink: 0;
display: flex;
color: #999;
padding: 0 4px;
}
.gpx-tree__download svg {
width: 14px;
height: 14px;
}
.gpx-tree__download:hover {
color: #333;
}
.gpx-tree__delete {
flex-shrink: 0;
border: none;
@@ -515,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;
@@ -540,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;
+35 -8
View File
@@ -6,7 +6,8 @@ import {
WAYPOINT_ICON,
TRACK_ICON,
ROUTE_ICON,
GEOJSON_ICON,
PENDING_ICON,
DOWNLOAD_ICON,
MAP_MARKER_ICON,
POI_ICONS,
PARK4NIGHT_TAGS,
@@ -271,15 +272,29 @@ export class GpxPanelControl {
checkbox.type = "checkbox";
checkbox.checked = this._visible.has(file.path);
checkbox.addEventListener("change", () => this._toggleFile(file.path, checkbox.checked, checkbox));
const name = document.createElement("span");
// Once a .gpx/.fit is cached, its converted GeoJSON is the thing that's
// actually shown on the map from here on (see GpxStore#geojson) — the
// name reflects that (matching the real "<path>.geojson" cache file on
// disk) rather than still reading as a plain, un-converted .gpx/.fit.
// A direct .geojson upload already ends in .geojson, so it's left
// alone. It's an <a>, not a <span>: clicking it downloads that GeoJSON
// (?download=1) — the only way to get it out of the app, since the
// original .gpx/.fit is never deleted but also never re-shown once
// converted. An <a> nested in a <label> doesn't also toggle the
// checkbox on click (unlike a plain element), so this doesn't fight
// the row's own toggle-on-map behavior.
const displayName = file.cached && !file.name.toLowerCase().endsWith(".geojson") ? `${file.name}.geojson` : file.name;
const name = document.createElement("a");
name.className = "gpx-tree__file-name";
name.textContent = file.name;
name.title = file.name;
name.href = `${gpxFileUrl(file.path)}?download=1`;
name.textContent = displayName;
name.title = `Download ${displayName}`;
label.appendChild(checkbox);
(file.kinds || []).forEach((kind) => label.appendChild(this._kindIcon(kind)));
if (file.cached) label.appendChild(this._cachedIcon());
if (!file.cached) label.appendChild(this._pendingIcon());
label.appendChild(name);
row.appendChild(label);
row.appendChild(this._downloadButton(file));
row.appendChild(this._deleteButton(() => this._deleteFile(file.path)));
return row;
@@ -301,14 +316,26 @@ export class GpxPanelControl {
return icon;
}
_cachedIcon() {
_pendingIcon() {
const icon = document.createElement("span");
icon.className = "gpx-tree__kind-icon";
icon.innerHTML = GEOJSON_ICON;
icon.title = "Ready to view instantly (GeoJSON cached)";
icon.innerHTML = PENDING_ICON;
icon.title = "Not yet converted — dropped straight into data/gpx/, not uploaded through the app";
return icon;
}
// Same destination as clicking the file's name (gpxFileUrl + ?download=1)
// — a second, more obviously clickable entry point for the same action.
_downloadButton(file) {
const link = document.createElement("a");
link.className = "gpx-tree__download";
link.href = `${gpxFileUrl(file.path)}?download=1`;
link.title = "Download GeoJSON";
link.setAttribute("aria-label", "Download GeoJSON");
link.innerHTML = DOWNLOAD_ICON;
return link;
}
_deleteButton(onClick) {
const button = document.createElement("button");
button.type = "button";
+19 -7
View File
@@ -54,14 +54,26 @@ export const WAYPOINT_ICON = `
export const TRACK_ICON = GPX_ICON;
// Shown next to a file whose GeoJSON conversion is already cached on disk
// (GpxStore#geojson) — ready to view instantly instead of paying REXML's
// parse cost on click. Curly braces read as "structured data ready", the
// same shorthand most editors/tools use for JSON.
export const GEOJSON_ICON = `
// Shown next to a file whose GeoJSON conversion is NOT yet cached on disk
// (GpxStore#geojson) — the uncommon case, since GpxStore#save converts
// eagerly at upload time; this only happens for a .gpx/.fit dropped
// straight into data/gpx/ on the filesystem, bypassing the app, and not
// yet opened. Everything uploaded normally is cached almost immediately,
// so badging the common case would just be noise — this badges the
// exception instead.
export const PENDING_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 4c-1.5 0-2 .8-2 2v3c0 1-.5 1.5-1.5 1.5 1 0 1.5.5 1.5 1.5v3c0 1.2.5 2 2 2"/>
<path d="M15 4c1.5 0 2 .8 2 2v3c0 1 .5 1.5 1.5 1.5-1 0-1.5.5-1.5 1.5v3c0 1.2-.5 2-2 2"/>
<circle cx="12" cy="12" r="8"/>
<path d="M12 8v4l3 2"/>
</svg>`;
// A small download button next to each tree file's delete button — the
// name link (see gpx.js's _renderFile) does the same thing, but isn't
// obviously clickable at a glance, so this is the discoverable version.
export const DOWNLOAD_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 4v11M8 11l4 4 4-4"/>
<path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/>
</svg>`;
// Same shape as TRACK_ICON, dashed — a route is planned rather than
+46 -4
View File
@@ -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.
+18
View File
@@ -72,6 +72,24 @@ class AppTest < Minitest::Test
assert_equal "Spot", json_body(file_response)["features"][0]["properties"]["name"]
end
def test_download_of_a_converted_gpx_names_it_gpx_geojson
content = File.read(fixture_path("sample.gpx"))
post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env)
response = request.get("/api/gpx/file/ride.gpx?download=1")
assert_equal 200, response.status
assert_includes response.headers["Content-Disposition"], 'filename="ride.gpx.geojson"'
end
def test_download_of_a_native_geojson_keeps_its_own_name
content = '{"type":"FeatureCollection","features":[]}'
post_file("/api/gpx", filename: "spots.geojson", content: content, env: logged_in_env)
response = request.get("/api/gpx/file/spots.geojson?download=1")
assert_equal 200, response.status
assert_includes response.headers["Content-Disposition"], 'filename="spots.geojson"'
end
def test_unparseable_gpx_returns_422
post_file("/api/gpx", filename: "bad.gpx", content: "<gpx><trk unclosed>", env: logged_in_env)